Compare commits

..
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 69d9906752 fix(browser): assert start_automation on send_json; run test_server.py in CI
#5818 moved start_automation delivery from send_text to send_json, but
test_ws_no_origin_cli_start_session still asserted send_text, and a
not-awaited check on send_text became vacuous. CI never ran test_server.py
(only test_protocol_compliance.py), so the break went unseen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 12:11:33 +00:00
166 changed files with 1759 additions and 14622 deletions

No files matched your search

@@ -313,11 +313,7 @@ runs:
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,Read,Write,Edit,Bash"
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
# Callers pass a YAML block (one tool per line). `read` below stops at
# the first newline, so join lines with commas or every tool after
# the first is silently dropped.
EXTRA_TOOLS=$(printf '%s' "$INPUT_ALLOWED_TOOLS" | tr '\n' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
TOOLS="$TOOLS,$EXTRA_TOOLS"
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
fi
# Remove any explicitly disallowed tools from the effective allow-list so
-7
View File
@@ -717,7 +717,6 @@ Methods:
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">state</a>()</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">stream</a>() -> bool</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_graph</a>() -> Dict[str, Any]</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_mermaid</a>() -> str</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">validate_variables</a>() -> None</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
@@ -771,8 +770,6 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">to_graph</a>() -> Dict[str, Any]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">to_mermaid</a>() -> str</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
@@ -901,13 +898,9 @@ Methods:
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay dashboard <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay delete <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay export <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay flow <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay list <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay rerun <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay show <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay view <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay watch <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai run <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.21" \
"praisonai>=4.7.18" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.21" \
"praisonai>=4.7.18" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.21" \
"praisonai>=4.7.18" \
"praisonai[ui]" \
"praisonai[crewai]"
-16
View File
@@ -1,16 +0,0 @@
# PraisonAI Replay: watch runs live
| Example | What it shows |
|---|---|
| `watch_flow.py` | Click **Run** and watch each step light up on the map; **Play** goes through it again. No API key. |
| `watch_team.py` | The same for two real agents (needs `OPENAI_API_KEY`). |
| `embed_fastapi.py` | Your own app starts a run and shows its progress in an `<iframe>` (`app.mount("/replay", replay_app())`). |
The same from the command line, for a YAML file:
```bash
praisonai replay watch agents.yaml
```
Install: `pip install "praisonai[ui]"`. Keep the page on localhost or behind your app's
login: anyone who can reach it can read the runs, and with a Run button, start one.
-60
View File
@@ -1,60 +0,0 @@
"""Show agent progress inside your own web app.
Your app starts the run and records it; the Replay view, mounted at /replay,
draws it live in an <iframe>. No dashboard, no second server.
pip install "praisonai[ui]" fastapi uvicorn
uvicorn embed_fastapi:app --port 8000 # open http://127.0.0.1:8000/
"""
import threading
import time
import uuid
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import StepResult
from praisonai.replay import record, replay_app
def research(ctx):
time.sleep(1.2)
return StepResult(output="notes")
def write(ctx):
time.sleep(1.2)
return StepResult(output="article")
flow = AgentFlow(name="Article", steps=[research, write])
app = FastAPI()
app.mount("/replay", replay_app()) # read-only view: /replay/?run=<id> shows one run
@app.post("/generate")
def generate():
run_id = f"run-{uuid.uuid4().hex[:12]}"
def job():
with record(run_id, live=True): # live: the view follows it as it is written
flow.start("solar sails")
threading.Thread(target=job, daemon=True).start()
return {"run_id": run_id}
@app.get("/", response_class=HTMLResponse)
def home():
return """<!doctype html><title>My app</title>
<h1>My app</h1>
<button id="go">Generate article</button>
<iframe id="progress" title="Progress" style="width:100%;height:640px;border:1px solid #ccc"></iframe>
<script>
go.onclick = async () => {
const { run_id } = await (await fetch('/generate', { method: 'POST' })).json();
progress.src = '/replay/?run=' + run_id;
};
</script>"""
-40
View File
@@ -1,40 +0,0 @@
"""Watch a flow run, step by step: click Run and each step lights up on the map.
No API key needed (the steps are plain Python that take a moment each).
pip install "praisonai[ui]"
python watch_flow.py # opens http://127.0.0.1:8090/
Map: dashed = not reached, blue = running now, green = done.
After a run, press Play to go through it again one step at a time.
"""
import time
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import StepResult
from praisonai.replay import watch
def research(ctx):
time.sleep(1.5)
return StepResult(output=f"3 sources about {ctx.input}")
def outline(ctx):
time.sleep(1.5)
return StepResult(output="intro, body, close")
def draft(ctx):
time.sleep(1.5)
return StepResult(output="first draft")
def review(ctx):
time.sleep(1.5)
return StepResult(output="approved")
flow = AgentFlow(name="Write an article", steps=[research, outline, draft, review])
watch(flow, input="solar sails")
-18
View File
@@ -1,18 +0,0 @@
"""Watch two agents work together, live (calls the LLM: needs OPENAI_API_KEY).
python watch_team.py # opens http://127.0.0.1:8090/, press Run
"""
from praisonaiagents import Agent, AgentTeam, Task
from praisonai.replay import watch
researcher = Agent(name="Researcher", instructions="Give 3 short facts.", llm="gpt-4o-mini")
writer = Agent(name="Writer", instructions="Write one short paragraph from the facts.", llm="gpt-4o-mini")
team = AgentTeam(
agents=[researcher, writer],
tasks=[
Task(name="research", description="3 facts about solar sails", agent=researcher, expected_output="3 facts"),
Task(name="write", description="One paragraph using the facts", agent=writer, expected_output="a paragraph"),
],
)
watch(team)
@@ -12,7 +12,7 @@ from collections import OrderedDict
import inspect
# Decomposed agent functionality - real implementations in mixin files
from .chat_mixin import ChatMixin, _deadline_scope
from .chat_mixin import ChatMixin
from .execution_mixin import ExecutionMixin
from .memory_mixin import MemoryMixin
from .async_memory_mixin import AsyncMemoryMixin
@@ -463,11 +463,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
concurrent turns on a shared agent do not share per-turn counters."""
if self._loop_guard is None:
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
config = LoopGuardConfig(enabled=True)
limit = getattr(self, "max_execution_time", None)
if limit is not None:
config.max_time_per_turn = float(limit) if limit > 0 else float("inf")
self._loop_guard = LoopGuard(config)
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
from .chat_mixin import _current_turn_loop_guard
turn_guard = _current_turn_loop_guard(self)
return turn_guard if turn_guard is not None else self._loop_guard
@@ -1917,17 +1913,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"Use Agent(policy=PolicyEngine(...)) for tool policy enforcement, "
"or pass a validator via GuardrailConfig(validator=...)."
)
# GuardrailConfig.on_fail has a single implemented policy (retry, then
# return the blocked message); refuse "skip"/"raise" rather than
# silently behaving as "retry".
if isinstance(_guardrails_config, GuardrailConfig) and (
getattr(_guardrails_config.on_fail, "value", _guardrails_config.on_fail) != "retry"
):
raise ValueError(
f"GuardrailConfig.on_fail={_guardrails_config.on_fail!r} is not supported; "
"only 'retry' is implemented. Remove on_fail, or handle a blocked "
"response via Agent.last_guardrail_error."
)
# Fail loud when the value could not be turned into an enforceable
# guardrail, instead of storing something that never runs. Silently
@@ -3298,12 +3283,6 @@ Your Goal: {self.goal}
if k in ("_db_run_context", "_db_session_init_lock"):
# A clone starts outside the source agent's active DB turn.
continue
elif k == "_tool_executor":
# Thread pools can't be copied; the clone lazily builds its own.
continue
elif k == "_Agent__hook_runner":
# Holds locks; rebuilt lazily on first use.
object.__setattr__(result, k, None)
elif k in ("_Agent__cache_lock",):
object.__setattr__(result, k, threading.RLock())
elif k in ("_cost_lock", "_auto_save_lock"):
@@ -4130,9 +4109,7 @@ Summary:"""
step budget from ``ExecutionConfig.max_steps`` was reached and the run was
truncated), a provider block/refusal/truncation
(``"content_filtered" | "refused" | "length_truncated"``) derived from the
LLM ``finish_reason``/refusal signal, ``"timeout"`` (the turn passed
``ExecutionConfig.max_execution_time`` or the ``run_autonomous``
deadline), or ``"error"``. Lets CLI/CI callers
LLM ``finish_reason``/refusal signal, or ``"error"``. Lets CLI/CI callers
branch on the terminal reason instead of parsing a magic string. Reads
from whichever backend (OpenAI-native or LiteLLM) executed the last turn.
"""
@@ -4750,7 +4727,7 @@ Summary:"""
if result.success:
print(result.output)
"""
from .autonomy import AutonomyResult, _timeout_result
from .autonomy import AutonomyResult
import time as time_module
from datetime import datetime, timezone
@@ -4789,11 +4766,6 @@ Summary:"""
effective_promise = completion_promise
if effective_promise is None:
effective_promise = self.autonomy_config.get("completion_promise")
# The promise is the only success signal, so tell the model about it
# unless the caller already did (e.g. the `loop` CLI instructions).
_promise_tag = f"<promise>{effective_promise}</promise>"
if effective_promise and _promise_tag not in prompt and _promise_tag not in (self.instructions or ""):
prompt = f"{prompt}\n\nWhen the task is fully complete, include {_promise_tag} in your response."
# Get clear_context from config if not explicitly set
effective_clear_context = clear_context
@@ -4824,10 +4796,17 @@ Summary:"""
)
# Check timeout
_timed_out = _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at)
if _timed_out is not None:
return _timed_out
if timeout_seconds and (time_module.time() - start_time) > timeout_seconds:
return AutonomyResult(
success=False,
output="Task timed out",
completion_reason="timeout",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# G2: Check for interrupt request (cooperative cancellation) - sync version
if self.interrupt_controller and self.interrupt_controller.is_set():
@@ -4850,17 +4829,9 @@ Summary:"""
# Reset the per-turn warning latch and inject any queued nudge.
turn_prompt = self._consume_self_correction(prompt)
try:
with _deadline_scope(timeout_seconds and timeout_seconds - (time_module.time() - start_time)):
response = self.chat(turn_prompt)
response = self.chat(turn_prompt)
except Exception as e:
# The run deadline cancels an overrunning turn; report it as a timeout.
# A budget stop keeps its typed budget_exhausted result when a cap applies.
return _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at
) or (isinstance(e, BudgetExceededError) and self._autonomy_budget_result(
iterations, stage, actions_taken, start_time, started_at,
"", spend_baseline
)) or AutonomyResult(
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
@@ -4872,14 +4843,6 @@ Summary:"""
started_at=started_at,
)
# A turn that overran the run deadline or max_execution_time
# must not count as progress.
_timed_out = _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at,
turn_timed_out=getattr(self, "_last_stop_reason", None) == "timeout")
if _timed_out is not None:
return _timed_out
response_str = str(response)
# Spend kill-switch: halt if cumulative cost/tokens exceed cap.
@@ -5045,7 +5008,7 @@ Summary:"""
# the task naturally (model stopped calling tools = done).
# This is the same signal the CLI/wrapper path trusts.
# ─────────────────────────────────────────────────────────────
if not effective_promise and self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="tool_completion",
@@ -5082,8 +5045,7 @@ Summary:"""
if obs is not None:
obs.emit(_EvtType.STAGE_ESCALATE, {"from": prev_stage, "to": stage})
# Completion promise: when configured, it is the only success signal
# (heuristic exits above/below are skipped in promise mode)
# Check for completion promise FIRST (structured signal)
if effective_promise:
promise_tag = f"<promise>{effective_promise}</promise>"
if promise_tag in response_str:
@@ -5105,7 +5067,7 @@ Summary:"""
)
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
if not effective_promise and self._is_completion_signal(response_str):
if self._is_completion_signal(response_str):
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="goal",
@@ -5124,7 +5086,7 @@ Summary:"""
)
# For DIRECT stage, complete after first response
if not effective_promise and stage == "direct":
if stage == "direct":
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="goal",
@@ -5152,7 +5114,7 @@ Summary:"""
# Suppressed when doom recovery is active — once a doom loop
# is detected, no_tool_calls (a success exit) should not fire;
# the doom loop system manages termination instead.
if (not effective_promise and self._autonomy_turn_tool_count == 0 and iterations > 1
if (self._autonomy_turn_tool_count == 0 and iterations > 1
and not self._doom_recovery_active):
self._consecutive_no_tool_turns += 1
if self._consecutive_no_tool_turns >= 2:
@@ -5264,7 +5226,7 @@ Summary:"""
asyncio.run(main())
"""
from .autonomy import AutonomyResult, _timeout_result
from .autonomy import AutonomyResult
import time as time_module
from datetime import datetime, timezone
import asyncio
@@ -5291,11 +5253,6 @@ Summary:"""
effective_promise = completion_promise
if effective_promise is None:
effective_promise = self.autonomy_config.get("completion_promise")
# The promise is the only success signal, so tell the model about it
# unless the caller already did (e.g. the `loop` CLI instructions).
_promise_tag = f"<promise>{effective_promise}</promise>"
if effective_promise and _promise_tag not in prompt and _promise_tag not in (self.instructions or ""):
prompt = f"{prompt}\n\nWhen the task is fully complete, include {_promise_tag} in your response."
# Get clear_context from config if not explicitly set
effective_clear_context = clear_context
@@ -5316,10 +5273,17 @@ Summary:"""
iterations += 1
# Check timeout
_timed_out = _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at)
if _timed_out is not None:
return _timed_out
if timeout_seconds and (time_module.time() - start_time) > timeout_seconds:
return AutonomyResult(
success=False,
output="Task timed out",
completion_reason="timeout",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# G2: Check for interrupt request (cooperative cancellation)
if self.interrupt_controller and self.interrupt_controller.is_set():
@@ -5343,17 +5307,9 @@ Summary:"""
# Reset the per-turn warning latch and inject any queued nudge.
turn_prompt = self._consume_self_correction(prompt)
try:
with _deadline_scope(timeout_seconds and timeout_seconds - (time_module.time() - start_time)):
response = await self.achat(turn_prompt)
response = await self.achat(turn_prompt)
except Exception as e:
# The run deadline cancels an overrunning turn; report it as a timeout.
# A budget stop keeps its typed budget_exhausted result when a cap applies.
return _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at
) or (isinstance(e, BudgetExceededError) and self._autonomy_budget_result(
iterations, stage, actions_taken, start_time, started_at,
"", spend_baseline
)) or AutonomyResult(
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
@@ -5365,14 +5321,6 @@ Summary:"""
started_at=started_at,
)
# A turn that overran the run deadline or max_execution_time
# must not count as progress.
_timed_out = _timeout_result(
timeout_seconds, iterations, stage, actions_taken, start_time, started_at,
turn_timed_out=getattr(self, "_last_stop_reason", None) == "timeout")
if _timed_out is not None:
return _timed_out
response_str = str(response)
# Spend kill-switch: halt if cumulative cost/tokens exceed cap.
@@ -5527,7 +5475,7 @@ Summary:"""
# produced a substantive response, the inner loop completed
# the task naturally (model stopped calling tools = done).
# ─────────────────────────────────────────────────────────────
if not effective_promise and self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
return AutonomyResult(
success=True,
output=response_str,
@@ -5550,8 +5498,7 @@ Summary:"""
if obs is not None:
obs.emit(_EvtType.STAGE_ESCALATE, {"from": stage_order[idx], "to": stage})
# Completion promise: when configured, it is the only success signal
# (heuristic exits above/below are skipped in promise mode)
# Check for completion promise FIRST (structured signal)
if effective_promise:
promise_tag = f"<promise>{effective_promise}</promise>"
if promise_tag in response_str:
@@ -5567,7 +5514,7 @@ Summary:"""
)
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
if not effective_promise and self._is_completion_signal(response_str):
if self._is_completion_signal(response_str):
return AutonomyResult(
success=True,
output=response_str,
@@ -5580,7 +5527,7 @@ Summary:"""
)
# For DIRECT stage, complete after first response
if not effective_promise and stage == "direct":
if stage == "direct":
return AutonomyResult(
success=True,
output=response_str,
@@ -5601,7 +5548,7 @@ Summary:"""
# Suppressed when doom recovery is active — once a doom loop
# is detected, no_tool_calls (a success exit) should not fire;
# the doom loop system manages termination instead.
if (not effective_promise and self._autonomy_turn_tool_count == 0 and iterations > 1
if (self._autonomy_turn_tool_count == 0 and iterations > 1
and not self._doom_recovery_active):
self._consecutive_no_tool_turns += 1
if self._consecutive_no_tool_turns >= 2:
@@ -6715,19 +6662,10 @@ Summary:"""
if hasattr(self, 'llm_instance') and self.llm_instance:
llm = self.llm_instance
# rerank=True without rerank_model has no backend reranker,
# so use the zero-dependency keyword reranker; with
# rerank_model the vector store already reranks.
reranker = None
if rag_config_obj.rerank and not getattr(self._retrieval_config, "rerank_model", None):
from praisonaiagents.rag.retriever import SimpleReranker
reranker = SimpleReranker()
self._rag_instance = RAG(
knowledge=self.knowledge,
config=rag_config_obj,
llm=llm,
reranker=reranker,
)
except ImportError:
logging.warning("RAG module not available. Install with: pip install 'praisonaiagents[rag]'")
@@ -7153,9 +7091,6 @@ Answer:"""
def _process_handoffs(self):
"""Process handoffs and convert them to tools that can be used by the agent."""
if not self.handoffs and not (isinstance(self.tools, list) and self.tools):
return
# Import here to avoid circular imports
from .handoff import Handoff
@@ -400,26 +400,6 @@ class AutonomyResult:
"""
return self.output or ""
def _timeout_result(timeout_seconds, iterations, stage, actions, start_time, started_at,
turn_timed_out=False):
"""Return a ``timeout`` AutonomyResult once a run is past ``timeout_seconds``
or the last turn was cut by ``max_execution_time`` (``turn_timed_out``), else None."""
import time
if not turn_timed_out and (not timeout_seconds or (time.time() - start_time) <= timeout_seconds):
return None
return AutonomyResult(
success=False,
output="Task timed out",
completion_reason="timeout",
iterations=iterations,
stage=stage,
actions=actions,
duration_seconds=time.time() - start_time,
started_at=started_at,
)
class DoomLoopTracker:
"""Tracks actions to detect doom loops with graduated recovery.
@@ -6,7 +6,6 @@ and response processing. Extracted from agent.py for maintainability.
"""
import asyncio
import contextlib
import contextvars
import os
import re
@@ -30,14 +29,6 @@ _turns_lock = threading.Lock()
# into tool worker threads with the context, so each turn has its own counters and
# overlapping turns on a shared agent never exhaust each other's limits.
_turn_loop_guard = contextvars.ContextVar("praisonai_turn_loop_guard", default=None)
# Agent whose streamed turn already records agent_start/agent_end, set while its
# non-streaming chat() fallback runs so that turn is not recorded twice.
_stream_traced_agent = contextvars.ContextVar("praisonai_stream_traced_agent", default=None)
def _agent_end_status(db_status: str) -> str:
"""``agent_end`` status for replay: completed, failed or cancelled."""
return "failed" if db_status == "error" else db_status
def _enter_turn(agent, cancel):
@@ -106,50 +97,20 @@ def _sleep_unless_cancelled(seconds: float, cancel_token: Any = None, step: floa
time.sleep(min(step, remaining))
# ``time.monotonic()`` deadline for turns started in this context, set by
# ``run_autonomous(timeout_seconds=...)`` so a single long turn cannot overrun it.
_run_deadline = contextvars.ContextVar("praisonai_run_deadline", default=None)
@contextlib.contextmanager
def _deadline_scope(seconds):
"""Bound every turn started in this block to ``seconds`` from now (no-op when falsy)."""
if not seconds:
yield
return
deadline = time.monotonic() + seconds
outer = _run_deadline.get()
token = _run_deadline.set(deadline if outer is None else min(deadline, outer))
try:
yield
finally:
_run_deadline.reset(token)
class _TurnCancelToken:
"""Track cancellation observed by one call without shared backend state.
An optional ``deadline`` (``time.monotonic()``) makes the token report set
once it passes, so the existing cancel checks also stop an overrunning turn.
"""
"""Track cancellation observed by one call without shared backend state."""
def __init__(
self,
source: Any,
*,
turn_id: Optional[int] = None,
deadline: Optional[float] = None,
):
self._source = source
self._observed = False
self._turn_id = turn_id
self._deadline = deadline
self.timed_out = False
def is_set(self) -> bool:
if self._deadline is not None and time.monotonic() >= self._deadline:
self.timed_out = self._observed = True
return True
turn_checker = getattr(self._source, "_turn_is_cancelled", None)
if self._turn_id is not None and callable(turn_checker):
is_set = bool(turn_checker(self._turn_id))
@@ -160,8 +121,6 @@ class _TurnCancelToken:
@property
def reason(self):
if self.timed_out:
return "timeout"
return getattr(self._source, "reason", None)
def check(self) -> None:
@@ -198,22 +157,13 @@ class ChatMixin:
return get_durable_run()
def _turn_cancel_token(self, source: Any, *, explicit: bool):
"""Create per-turn cancellation state while consuming default requests once.
``ExecutionConfig.max_execution_time`` and any enclosing
``run_autonomous`` timeout become the token's deadline.
"""
limit = getattr(self, "max_execution_time", None)
deadline = time.monotonic() + limit if limit else None
outer = _run_deadline.get()
if outer is not None:
deadline = outer if deadline is None else min(deadline, outer)
"""Create per-turn cancellation state while consuming default requests once."""
if source is None:
return None if deadline is None else _TurnCancelToken(None, deadline=deadline)
return None
begin_turn = getattr(source, "_begin_turn", None)
if explicit or not callable(begin_turn):
return _TurnCancelToken(source, deadline=deadline)
return _TurnCancelToken(source, turn_id=begin_turn(), deadline=deadline)
return _TurnCancelToken(source)
return _TurnCancelToken(source, turn_id=begin_turn())
@staticmethod
def _notify_tool_call(tool_name, tool_input, tool_result, elapsed_time=None, success=True):
@@ -1962,107 +1912,6 @@ Your Goal: {self.goal}"""
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
return create_llm_dispatcher(openai_client=self._openai_client, model=model)
def _fallback_llm_for(self, model):
"""Build a throwaway LLM bound to fallback ``model`` for the custom-LLM path.
Never assigned to ``self.llm_instance``, so concurrent turns keep the
primary model. A fallback entry that is already a model object is used
as-is. Option params (metrics, max_iter, retries, ...) are inherited;
connection settings (base_url, api_key, auth) only when the fallback
targets the same provider prefix as the primary.
"""
if not isinstance(model, str):
return model
from ..llm.llm import LLM
params = dict(getattr(self, '_llm_init_params', None) or {})
primary = str(params.get('model') or self.llm or '')
# Bare names (no "provider/" prefix) share the configured server.
prefix = lambda name: name.split('/', 1)[0] if '/' in name else ''
if prefix(primary) != prefix(model):
for key in ('base_url', 'api_key', 'auth'):
params.pop(key, None)
params['model'] = model
return LLM(**params)
def _classify_custom_llm_fallback(self, error, failed_model, fallback_index):
"""Return ``(next_model, category)`` if ``error`` warrants a model fallback."""
next_model = self._get_next_fallback_model(fallback_index)
if next_model is None:
return None, None
from ..llm.error_classifier import classify_llm_error
model_name = str(failed_model)
provider = model_name.split('/', 1)[0] if '/' in model_name else "openai"
classification = classify_llm_error(error, provider=provider, model=model_name)
if not classification.should_fallback_model:
return None, None
logging.info(f"[{self.name}] {model_name} unavailable — falling back to {next_model}")
return next_model, classification.error_category
@staticmethod
def _track_tool_runs(llm_kwargs):
"""Return ``(llm_kwargs, ran)``; ``ran`` becomes non-empty once a tool executes.
A fallback replays the whole turn from the original messages, so it is
only safe while no tool has run (otherwise side effects would repeat).
"""
import functools
import inspect
ran = []
fn = llm_kwargs.get('execute_tool_fn')
if fn is None:
return llm_kwargs, ran
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def tracked(*args, **kwargs):
ran.append(True)
return await fn(*args, **kwargs)
else:
@functools.wraps(fn)
def tracked(*args, **kwargs):
ran.append(True)
return fn(*args, **kwargs)
return {**llm_kwargs, 'execute_tool_fn': tracked}, ran
def _custom_llm_get_response(self, llm_kwargs):
"""Call ``llm_instance.get_response`` walking ``fallback_models`` on outage."""
llm, index, current = self.llm_instance, 0, self.llm
stream_cb = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
llm_kwargs, tools_ran = self._track_tool_runs(llm_kwargs)
while True:
try:
with self._llm_usage_sink(llm):
return llm.get_response(**llm_kwargs)
except (ToolExecutionError, BudgetExceededError, InterruptedError):
raise
except Exception as e:
if tools_ran:
raise
next_model, category = self._classify_custom_llm_fallback(e, current, index)
if next_model is None:
raise
self._emit_model_fallback(current, next_model, category, index, stream_cb)
llm, index, current = self._fallback_llm_for(next_model), index + 1, next_model
async def _custom_llm_get_response_async(self, llm_kwargs):
"""Async twin of :meth:`_custom_llm_get_response`."""
llm, index, current = self.llm_instance, 0, self.llm
stream_cb = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
llm_kwargs, tools_ran = self._track_tool_runs(llm_kwargs)
while True:
try:
with self._llm_usage_sink(llm):
return await llm.get_response_async(**llm_kwargs)
except (ToolExecutionError, BudgetExceededError, InterruptedError):
raise
except Exception as e:
if tools_ran:
raise
next_model, category = self._classify_custom_llm_fallback(e, current, index)
if next_model is None:
raise
await self._aemit_model_fallback(current, next_model, category, index, stream_cb)
llm, index, current = self._fallback_llm_for(next_model), index + 1, next_model
def _get_model_fallback_lock(self):
"""Lazily create the per-instance lock guarding model-fallback swaps.
@@ -2226,82 +2075,6 @@ Your Goal: {self.goal}"""
return max_retries
return 2
def _extract_llm_usage(self, response, model=None):
"""Return ``(prompt_tokens, completion_tokens, cost_usd)`` for one LLM response priced as ``model``."""
usage = getattr(response, 'usage', None) if response else None
if not usage:
return 0, 0, 0.0
# Chat Completions names, then the Responses API names.
prompt_tokens = getattr(usage, 'prompt_tokens', 0) or getattr(usage, 'input_tokens', 0) or 0
completion_tokens = getattr(usage, 'completion_tokens', 0) or getattr(usage, 'output_tokens', 0) or 0
# Calculate cost using litellm (if available) or fallback pricing
cost_usd = self._calculate_llm_cost(prompt_tokens, completion_tokens, response=response, model=model)
return prompt_tokens, completion_tokens, cost_usd
def _record_llm_usage(self, prompt_tokens, completion_tokens, cost_usd):
"""Add one LLM call to the agent's cost/token totals and enforce ``max_budget``.
The single accounting point for every path: the OpenAI-native
completions call it directly and the custom-LLM (``provider/model``)
tool loop reaches it through the LLM usage sink, once per call.
"""
# Thread-safe cost tracking (Gap 1a fix)
with self._cost_lock:
self._total_cost += cost_usd
self._total_tokens_in += prompt_tokens
self._total_tokens_out += completion_tokens
self._llm_call_count += 1
budget_exceeded = self._max_budget and self._total_cost >= self._max_budget
current_cost = self._total_cost
if budget_exceeded:
if self._on_budget_exceeded == "stop":
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=current_cost,
agent_id=self.name
)
elif self._on_budget_exceeded == "warn":
logging.warning(
f"[budget] {self.name}: ${current_cost:.4f} exceeded "
f"${self._max_budget:.4f} budget"
)
elif callable(self._on_budget_exceeded):
self._on_budget_exceeded(current_cost, self._max_budget)
@contextlib.contextmanager
def _llm_usage_sink(self, llm=None):
"""Route every call ``llm`` (default: the custom LLM) makes in this block into ``_record_llm_usage``.
Refuses to start when ``max_budget`` is already spent (``"stop"`` mode),
so retrying after ``BudgetExceededError`` sends no further paid request.
"""
if self._max_budget and self._on_budget_exceeded == "stop":
with self._cost_lock:
current_cost = self._total_cost
if current_cost >= self._max_budget:
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=current_cost,
agent_id=self.name
)
from ..llm.llm import _usage_sink
llm = llm if llm is not None else self.llm_instance
# A fallback model is priced as itself, not as the primary ``self.llm``.
model = None if llm is self.llm_instance else getattr(llm, 'model', None)
token = _usage_sink.set((
llm,
lambda response: self._record_llm_usage(*self._extract_llm_usage(response, model)),
))
try:
yield
finally:
_usage_sink.reset(token)
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, cancel_token=None):
start_time = time.time()
@@ -2501,8 +2274,19 @@ Your Goal: {self.goal}"""
# Emit LLM response trace event with token usage
_duration_ms = (time.time() - start_time) * 1000
_prompt_tokens, _completion_tokens, _cost_usd = self._extract_llm_usage(final_response)
_prompt_tokens = 0
_completion_tokens = 0
_cost_usd = 0.0
# Extract token usage from response if available
if final_response:
_usage = getattr(final_response, 'usage', None)
if _usage:
_prompt_tokens = getattr(_usage, 'prompt_tokens', 0) or 0
_completion_tokens = getattr(_usage, 'completion_tokens', 0) or 0
# Calculate cost using litellm (if available) or fallback pricing
_cost_usd = self._calculate_llm_cost(_prompt_tokens, _completion_tokens, response=final_response)
_trace_emitter.llm_response(
self.name,
duration_ms=_duration_ms,
@@ -2512,8 +2296,33 @@ Your Goal: {self.goal}"""
cost_usd=_cost_usd,
)
self._record_llm_usage(_prompt_tokens, _completion_tokens, _cost_usd)
# Budget tracking & enforcement (zero overhead when _max_budget is None)
# Thread-safe cost tracking (Gap 1a fix)
with self._cost_lock:
self._total_cost += _cost_usd
self._total_tokens_in += _prompt_tokens
self._total_tokens_out += _completion_tokens
self._llm_call_count += 1
budget_exceeded = self._max_budget and self._total_cost >= self._max_budget
current_cost = self._total_cost
if budget_exceeded:
if self._on_budget_exceeded == "stop":
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=current_cost,
agent_id=self.name
)
elif self._on_budget_exceeded == "warn":
logging.warning(
f"[budget] {self.name}: ${current_cost:.4f} exceeded "
f"${self._max_budget:.4f} budget"
)
elif callable(self._on_budget_exceeded):
self._on_budget_exceeded(current_cost, self._max_budget)
# Trigger AFTER_LLM hook
# Only build the input if an AFTER_LLM hook is actually registered
from ..hooks import HookEvent, AfterLLMInput
@@ -2527,7 +2336,6 @@ Your Goal: {self.goal}"""
messages=messages,
response=str(final_response),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
tokens_used=_prompt_tokens + _completion_tokens,
latency_ms=(time.time() - start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after_llm_input)
@@ -3182,8 +2990,40 @@ Your Goal: {self.goal}"""
)
# Post-call budget accounting (parity with sync _chat_completion).
_prompt_tokens, _completion_tokens, _cost_usd = self._extract_llm_usage(final_response)
self._record_llm_usage(_prompt_tokens, _completion_tokens, _cost_usd)
_prompt_tokens = 0
_completion_tokens = 0
_cost_usd = 0.0
if final_response:
_usage = getattr(final_response, 'usage', None)
if _usage:
_prompt_tokens = getattr(_usage, 'prompt_tokens', 0) or 0
_completion_tokens = getattr(_usage, 'completion_tokens', 0) or 0
_cost_usd = self._calculate_llm_cost(
_prompt_tokens, _completion_tokens, response=final_response
)
with self._cost_lock:
self._total_cost += _cost_usd
self._total_tokens_in += _prompt_tokens
self._total_tokens_out += _completion_tokens
self._llm_call_count += 1
budget_exceeded = self._max_budget and self._total_cost >= self._max_budget
current_cost = self._total_cost
if budget_exceeded:
if self._on_budget_exceeded == "stop":
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=current_cost,
agent_id=self.name
)
elif self._on_budget_exceeded == "warn":
logging.warning(
f"[budget] {self.name}: ${current_cost:.4f} exceeded "
f"${self._max_budget:.4f} budget"
)
elif callable(self._on_budget_exceeded):
self._on_budget_exceeded(current_cost, self._max_budget)
# Trigger AFTER_LLM hook (parity with sync _chat_completion).
from ..hooks import AfterLLMInput
@@ -3197,7 +3037,6 @@ Your Goal: {self.goal}"""
messages=messages,
response=str(final_response),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
tokens_used=_prompt_tokens + _completion_tokens,
latency_ms=(time.time() - _achat_start_time) * 1000
)
await self._hook_runner.execute(HookEvent.AFTER_LLM, after_llm_input)
@@ -3609,9 +3448,7 @@ Your Goal: {self.goal}"""
# Emit context trace event (zero overhead when not set)
from ..trace.context_events import get_context_emitter
_trace_emitter = get_context_emitter()
_trace_turn = _stream_traced_agent.get() is not self
if _trace_turn:
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
durable_context = None
durable_token = None
previous_run_state = self._get_db_run_state()
@@ -3680,15 +3517,12 @@ Your Goal: {self.goal}"""
end_durable_run(durable_token)
durable_context.close()
try:
if _trace_turn:
_trace_emitter.agent_end(self.name, {"status": _agent_end_status(db_status)})
_trace_emitter.agent_end(self.name)
finally:
if getattr(self, "_active_turn_token", None) is _cancel:
self._active_turn_token = None
_exit_turn(self, _cancel_ctx)
if _cancel is not None:
if getattr(_cancel, "timed_out", False):
self._last_stop_reason = "timeout"
_cancel.close()
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
@@ -4015,7 +3849,7 @@ Your Goal: {self.goal}"""
if hasattr(self, '_drain_steering_messages') and getattr(self, '_message_steering', None) is not None:
llm_kwargs['steering_drain'] = self._drain_steering_messages
response_text = self._custom_llm_get_response(llm_kwargs)
response_text = self.llm_instance.get_response(**llm_kwargs)
self._add_to_chat_history("assistant", response_text)
# Persist assistant message to DB
@@ -4046,7 +3880,7 @@ Your Goal: {self.goal}"""
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return self._guardrail_blocked_message(e)
except (ToolExecutionError, BudgetExceededError):
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
@@ -4054,7 +3888,7 @@ Your Goal: {self.goal}"""
self._rollback_chat_history_to(chat_history_length)
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
return None
except (ToolExecutionError, BudgetExceededError):
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
@@ -4406,17 +4240,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception as e:
logger.warning(f"Steering check failed, continuing without steering: {e}")
# Input-side validators may perform blocking LLM calls. Keep them off
# the event loop, before any backend/LLM dispatch, just like tool guards.
# Input-side guardrail validation (parity with sync chat()). No-op
# unless the guardrail exposes validate_input; runs before any
# backend/LLM dispatch so a rejected prompt never reaches the model.
if hasattr(self, '_validate_input_with_guardrail'):
_input_guard = self._guardrail_fn
if (_input_guard is None or not hasattr(_input_guard, "validate_input")
or getattr(_input_guard, "_praison_output_only", False)):
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
else:
_in_ok, _in_prompt, _in_err = await asyncio.to_thread(
self._validate_input_with_guardrail, prompt
)
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
if not _in_ok:
logging.warning(f"Agent {self.name}: input blocked by guardrail: {_in_err}")
return None
@@ -4532,14 +4360,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
await durable_context.aclose()
finally:
try:
_trace_emitter.agent_end(self.name, {"status": _agent_end_status(db_status)})
_trace_emitter.agent_end(self.name)
finally:
if getattr(self, "_active_turn_token", None) is _cancel:
self._active_turn_token = None
_exit_turn(self, _cancel_ctx)
if _cancel is not None:
if getattr(_cancel, "timed_out", False):
self._last_stop_reason = "timeout"
_cancel.close()
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
@@ -4750,7 +4576,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if hasattr(self, '_drain_steering_messages') and getattr(self, '_message_steering', None) is not None:
llm_kwargs['steering_drain'] = self._drain_steering_messages
response_text = await self._custom_llm_get_response_async(llm_kwargs)
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
# LLM call succeeded - now it's safe to commit any compacted history
if effective_history is not self.chat_history:
@@ -4778,7 +4604,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return self._guardrail_blocked_message(e)
except (ToolExecutionError, BudgetExceededError):
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
@@ -5292,9 +5118,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except ToolExecutionError:
raise
except Exception as e:
# The custom-LLM path raises on an exhausted budget (sync parity).
if isinstance(e, BudgetExceededError) and self._using_custom_llm:
raise
self._last_stop_reason = "error"
_get_display_functions()['display_error'](f"Error in achat: {e}")
if get_logger().getEffectiveLevel() == logging.DEBUG:
@@ -5496,26 +5319,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# via _enter_turn) so overlapping streams on a shared agent neither
# exhaust nor inherit each other's per-turn limits.
guard_token = _turn_loop_guard.set([self, None])
# Record the streamed turn like chat()/achat() do, so a live replay can
# show the agent running while its words stream (no-op when tracing is off).
from ..trace.context_events import get_context_emitter
_trace_emitter = get_context_emitter()
status = "completed"
if _trace_emitter._enabled:
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
try:
# _start_stream_turn returns "failed" when a guardrail or hook blocks
# the turn, matching chat(), which records a blocked turn as failed.
status = (yield from self._start_stream_turn(prompt, **kwargs)) or status
except (GeneratorExit, InterruptedError):
status = "cancelled"
raise
except BaseException:
status = "failed"
raise
yield from self._start_stream_turn(prompt, **kwargs)
finally:
if _trace_emitter._enabled:
_trace_emitter.agent_end(self.name, {"status": status})
try:
_turn_loop_guard.reset(guard_token)
except (ValueError, LookupError):
@@ -5540,7 +5346,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if not _in_ok:
logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}")
yield f"[Input blocked by guardrail: {_in_err}]"
return "failed"
return
prompt = _in_prompt
# Same pre-turn checks as chat()/achat(): a streamed turn must not
@@ -5561,7 +5367,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
))
if self._hook_runner.is_blocked(hook_results):
logging.warning(f"Agent {self.name} execution blocked by BEFORE_AGENT hook")
return "failed"
return
for res in hook_results:
if res.output and res.output.modified_input and "prompt" in res.output.modified_input:
prompt = res.output.modified_input["prompt"]
@@ -6381,9 +6187,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
else:
fallback_kwargs.pop('stream', None)
# The streamed turn already records agent_start/agent_end; keep chat()
# from recording the same turn a second time.
_traced = _stream_traced_agent.set(self)
try:
return self.chat(prompt, **fallback_kwargs)
except Exception as fallback_error:
@@ -6396,8 +6199,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception:
pass
raise fallback_error from streaming_error
finally:
_stream_traced_agent.reset(_traced)
def _create_llm_summarize_function(self):
"""
@@ -6867,57 +6668,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
raise RuntimeError("Retry loop completed without returning or raising an exception")
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 model call with retry, recorded as ``llm_request``/``llm_response`` like the sync path.
``Agent.astart``/``achat`` recorded no model calls before this, so a replay
of an async run showed the agent but never what it asked the model.
"""
from ..trace.context_events import get_context_emitter
_trace_emitter = get_context_emitter()
call = self._achat_completion_with_retry_dispatch(
messages, temperature, tools, stream, reasoning_steps, task_name,
task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events,
)
if not _trace_emitter._enabled: # zero overhead when tracing is off
return await call
_trace_emitter.llm_request(
self.name,
messages_count=len(messages),
tokens_used=0,
model=self.llm if isinstance(self.llm, str) else None,
messages=messages,
)
started = time.time()
try:
response = await call
except Exception as e:
_trace_emitter.llm_response(
self.name,
duration_ms=(time.time() - started) * 1000,
finish_reason="error",
response_content=str(e),
)
raise
prompt_tokens = completion_tokens = 0
cost_usd = 0.0
usage = getattr(response, "usage", None)
if usage:
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
cost_usd = self._calculate_llm_cost(prompt_tokens, completion_tokens, response=response)
_trace_emitter.llm_response(
self.name,
duration_ms=(time.time() - started) * 1000,
response_content=self._extract_llm_response_content(response),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cost_usd=cost_usd,
)
return response
async def _achat_completion_with_retry_dispatch(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.
@@ -400,8 +400,8 @@ This report contains all agent interactions and outputs from a complete ContextA
response_filename = f"{self.interaction_counter:03d}_{safe_agent_name}_{timestamp.replace(':', '_')}.json"
response_path = os.path.join(self.output_dir, "agent_responses", response_filename)
with open(response_path, "w", encoding="utf-8") as f:
json.dump(interaction_data, f, indent=2, ensure_ascii=False)
with open(response_path, "w") as f:
json.dump(interaction_data, f, indent=2)
# Save individual markdown files - debug only
md_filename = f"{self.interaction_counter:03d}_{safe_agent_name}_{timestamp.replace(':', '_')}"
@@ -1201,20 +1201,20 @@ detailed, actionable implementation steps."""
def _save_context_engineering_results(self):
"""Save all Context Engineering results following PRD methodology."""
# Save comprehensive analysis
with open(os.path.join(self.output_dir, "comprehensive_analysis.json"), "w", encoding="utf-8") as f:
json.dump(self.codebase_analysis, f, indent=2, ensure_ascii=False)
with open(os.path.join(self.output_dir, "comprehensive_analysis.json"), "w") as f:
json.dump(self.codebase_analysis, f, indent=2)
# Save pattern library
with open(os.path.join(self.output_dir, "pattern_library.json"), "w", encoding="utf-8") as f:
json.dump(self.pattern_library, f, indent=2, ensure_ascii=False)
with open(os.path.join(self.output_dir, "pattern_library.json"), "w") as f:
json.dump(self.pattern_library, f, indent=2)
# Save validation framework
with open(os.path.join(self.output_dir, "validation_framework.json"), "w", encoding="utf-8") as f:
json.dump(self.validation_framework, f, indent=2, ensure_ascii=False)
with open(os.path.join(self.output_dir, "validation_framework.json"), "w") as f:
json.dump(self.validation_framework, f, indent=2)
# Save all agent interactions summary
with open(os.path.join(self.output_dir, "all_agent_interactions.json"), "w", encoding="utf-8") as f:
json.dump(self.agent_interactions, f, indent=2, ensure_ascii=False)
with open(os.path.join(self.output_dir, "all_agent_interactions.json"), "w") as f:
json.dump(self.agent_interactions, f, indent=2)
print(f"💾 Context Engineering results saved to: {self.output_dir}/")
print(f"💾 Agent interactions ({len(self.agent_interactions)} total) saved to: agent_responses/")
@@ -1267,7 +1267,7 @@ Product Requirements Prompts following PRD template methodology.
Every agent interaction has been saved for full audit trail and reproducibility.
"""
with open(os.path.join(self.output_dir, "analysis_summary.md"), "w", encoding="utf-8") as f:
with open(os.path.join(self.output_dir, "analysis_summary.md"), "w") as f:
f.write(summary)
print(f"📋 Analysis summary saved following PRD methodology")
@@ -1308,7 +1308,7 @@ Every agent interaction has been saved for full audit trail and reproducibility.
safe_filename = feature_request.lower().replace(' ', '_').replace('-', '_')[:50]
prp_path = os.path.join(self.output_dir, "PRPs", f"{safe_filename}.md")
with open(prp_path, "w", encoding="utf-8") as f:
with open(prp_path, "w") as f:
f.write(prp)
print(f"📄 Comprehensive PRP following PRD template saved to: {prp_path}")
@@ -497,111 +497,157 @@ class ExecutionMixin:
# The backend's stream method is async, so we need to handle that
import asyncio
import threading
import queue
def _to_text(item):
# Managed backends may yield event dicts; convert them to the
# string chunks expected by Agent and skip other event types
# (session.status_idle, etc.).
if isinstance(item, str):
return item
if isinstance(item, dict) and item.get('type') == 'agent.message':
content = item.get('content', [])
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
text_parts.append(block.get('text', ''))
elif isinstance(block, str):
text_parts.append(block)
if text_parts:
return ''.join(text_parts)
return None
# Convert async generator to sync generator. The backend's async
# generator is always driven on a private loop in a background
# thread, whether or not the caller already has a running loop, so
# the caller receives chunks incrementally and a backend error
# (including RuntimeError) can never re-run the stream.
async def stream_wrapper():
async for chunk in self.backend.stream(prompt, **kwargs):
yield chunk
# Convert async generator to sync generator
def sync_stream():
# Bounded queue so an abandoned consumer applies back-pressure
# instead of letting the producer fill memory unbounded.
# ``cancel_event`` lets the producer stop early when the caller
# closes the generator.
bridge_queue = queue.Queue(maxsize=64)
cancel_event = threading.Event()
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
async def pump():
agen = self.backend.stream(prompt, **kwargs)
try:
async for item in agen:
if cancel_event.is_set():
break
# Block until space is available or the
# consumer signals cancellation, so we
# never spin nor grow the queue forever.
while not cancel_event.is_set():
try:
bridge_queue.put(('item', item), timeout=0.1)
break
except queue.Full:
continue
if not cancel_event.is_set():
bridge_queue.put(('done', None))
except Exception as e:
if not cancel_event.is_set():
bridge_queue.put(('error', e))
finally:
# Run the backend's own cleanup (e.g. stopping
# its producer) when the consumer closes early.
aclose = getattr(agen, 'aclose', None)
if aclose is not None:
try:
await aclose()
except Exception as e:
logger.debug(f"Backend stream cleanup failed: {e}")
new_loop.run_until_complete(pump())
finally:
try:
loop = asyncio.get_running_loop()
# Already in async context - need to handle differently
import concurrent.futures
import threading
import queue
result_queue = queue.Queue()
exception_holder = [None]
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
new_loop.run_until_complete(new_loop.shutdown_asyncgens())
async def collect():
try:
async for item in self.backend.stream(prompt, **kwargs):
result_queue.put(('item', item))
result_queue.put(('done', None))
except Exception as e:
exception_holder[0] = e
result_queue.put(('error', e))
new_loop.run_until_complete(collect())
finally:
new_loop.close()
# Daemon thread so a stalled/abandoned producer can never
# block interpreter shutdown.
thread = threading.Thread(target=run_in_thread, daemon=True)
thread.start()
try:
thread = threading.Thread(target=run_in_thread)
thread.start()
while True:
msg_type, data = bridge_queue.get()
msg_type, data = result_queue.get()
if msg_type == 'item':
text = _to_text(data)
if text is not None:
yield text
# For managed backends, we might get event objects
# Convert to string format expected by Agent
if isinstance(data, dict):
if data.get('type') == 'agent.message':
content = data.get('content', [])
if isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
text_parts.append(block.get('text', ''))
elif isinstance(block, str):
text_parts.append(block)
if text_parts:
yield ''.join(text_parts)
elif isinstance(content, str):
yield content
# Skip other event types (session.status_idle, etc.)
elif isinstance(data, str):
yield data
elif msg_type == 'done':
break
elif msg_type == 'error':
raise data
finally:
# Runs on normal completion AND on early generator
# close/GC: signal the producer to stop, drain one slot
# so a blocked put() can observe the cancel, and join.
cancel_event.set()
try:
bridge_queue.get_nowait()
except queue.Empty:
pass
thread.join(timeout=5)
thread.join()
except RuntimeError:
# No event loop (the common script/CLI case). Drive the
# backend's async generator from a background thread and
# push each chunk through a queue as it arrives, so the
# caller receives chunks incrementally instead of only
# after the whole response has been buffered. Mirrors the
# running-loop branch above.
import threading
import queue
# Bounded queue so an abandoned consumer applies
# back-pressure instead of letting the producer fill memory
# unbounded. ``cancel_event`` lets the producer stop early
# when the caller closes the generator.
bridge_queue = queue.Queue(maxsize=64)
cancel_event = threading.Event()
def run_in_thread_noloop():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
async def pump():
try:
async for item in self.backend.stream(prompt, **kwargs):
if cancel_event.is_set():
break
# Block until space is available or the
# consumer signals cancellation, so we
# never spin nor grow the queue forever.
while not cancel_event.is_set():
try:
bridge_queue.put(('item', item), timeout=0.1)
break
except queue.Full:
continue
if not cancel_event.is_set():
bridge_queue.put(('done', None))
except Exception as e:
if not cancel_event.is_set():
bridge_queue.put(('error', e))
new_loop.run_until_complete(pump())
finally:
new_loop.close()
# Daemon thread so a stalled/abandoned producer can never
# block interpreter shutdown.
thread = threading.Thread(target=run_in_thread_noloop, daemon=True)
thread.start()
try:
while True:
msg_type, data = bridge_queue.get()
if msg_type == 'item':
item = data
# Same conversion logic as the running-loop branch.
if isinstance(item, dict):
if item.get('type') == 'agent.message':
content = item.get('content', [])
if isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
text_parts.append(block.get('text', ''))
elif isinstance(block, str):
text_parts.append(block)
if text_parts:
yield ''.join(text_parts)
elif isinstance(content, str):
yield content
elif isinstance(item, str):
yield item
elif msg_type == 'done':
break
elif msg_type == 'error':
raise data
finally:
# Runs on normal completion AND on early generator
# close/GC: signal the producer to stop, drain one slot
# so a blocked put() can observe the cancel, and join.
cancel_event.set()
try:
bridge_queue.get_nowait()
except queue.Empty:
pass
thread.join(timeout=5)
return sync_stream()
except Exception as e:
@@ -1381,10 +1427,6 @@ Write the complete compiled report:"""
_loop_detection.record_tool_outcome(
_ld_history, function_name, arguments, _result, _ld_config
)
# Per-turn tool count for autonomy completion heuristics (sync
# parity with tool_execution.py). Counted only once the tool has
# actually run, so a BEFORE_TOOL-blocked call cannot count.
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
return _result
# Enforce BEFORE_TOOL/AFTER_TOOL security hooks for every async caller,
@@ -1667,7 +1709,6 @@ Write the complete compiled report:"""
async def _execute_tool_async_impl(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Internal async tool execution implementation"""
from ..errors import ToolExecutionError
from .tool_execution import _CALLER_ERROR_TYPES
try:
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
@@ -1899,50 +1940,20 @@ Write the complete compiled report:"""
with tool_progress_channel(_progress_sink):
return await call_target(**call_arguments)
logging.debug(f"Executing sync function in executor: {function_name}")
loop = asyncio.get_running_loop()
from ..trace.context_events import copy_context_to_callable
# copy_context_to_callable snapshots the CURRENT context (incl.
# the injection contextvar) into the executor thread, so setting
# it here propagates cancel_event to the sync tool body too.
if _inject_state is not None:
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
return await _run_sync(copy_context_to_callable(lambda: call_target(**call_arguments)))
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
)
with tool_progress_channel(_progress_sink):
return await _run_sync(copy_context_to_callable(lambda: call_target(**call_arguments)))
async def _run_sync(fn):
# With a tool timeout, a timed-out sync tool keeps its thread
# (wait_for cannot stop it). Run it on the bounded per-agent
# pool (sync-path parity) instead of the shared default
# executor, so hung tools cannot starve asyncio.to_thread
# callers elsewhere in the process.
if not (tool_timeout and tool_timeout > 0) or not hasattr(self, "_submit_to_tool_pool"):
return await asyncio.get_running_loop().run_in_executor(None, fn)
# spill=True: when every worker is busy the call runs at once
# on a counted overflow thread (executor is None) instead of
# queueing into its timeout; refused once that cap is hit.
future, executor, orphaned = self._submit_to_tool_pool(fn, spill=True)
if future is None:
logging.warning(
f"Tool worker pool exhausted; refusing to queue '{function_name}' "
f"behind {orphaned} hung tool call(s)."
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
)
return {
"error": (
f"Tool worker pool exhausted: {orphaned} earlier tool call(s) "
f"are still running past their timeout. Refusing to queue "
f"'{function_name}'."
),
"tool_pool_exhausted": True,
"_praison_retryable": False,
}
try:
return await asyncio.wrap_future(future)
except asyncio.CancelledError:
# Timed out (or caller cancelled) while still running:
# retire the stuck pool so the next call gets a fresh worker.
if executor is not None and not future.done():
self._retire_tool_executor(executor)
raise
# Circuit-breaker parity with the sync path (tool_execution.py).
# The async tool path previously had no breaker, so a repeatedly
@@ -1962,12 +1973,23 @@ Write the complete compiled report:"""
_NeutralOutcome = _CircuitBreakerException
try:
from ..tools.circuit_breaker import (
get_circuit_breaker,
CircuitBreakerConfig,
CircuitBreakerException as _CircuitBreakerException,
_NeutralOutcome,
)
# Same per-agent breaker and policy as the sync path.
if hasattr(self, "_get_tool_circuit_breaker"):
breaker = self._get_tool_circuit_breaker(function_name)
breaker_name = f"tool_{id(self)}_{function_name}"
breaker = get_circuit_breaker(
breaker_name,
CircuitBreakerConfig(
failure_threshold=5,
recovery_timeout=60.0,
timeout=30.0,
graceful_degradation=True,
),
)
if hasattr(self, "_register_breaker_finalizer"):
self._register_breaker_finalizer(breaker_name)
except ImportError:
# Circuit breaker not available - fall back to direct invocation.
logging.debug("Circuit breaker not available on async path, executing directly")
@@ -1988,7 +2010,7 @@ Write the complete compiled report:"""
# half-open one. Unwrapped in _invoke_guarded.
try:
r = await _invoke()
except _CALLER_ERROR_TYPES as e:
except (ValueError, TypeError, AttributeError) as e:
raise _NeutralOutcome(e)
if isinstance(r, dict) and r.get("error") and \
r.get("_praison_retryable") is False:
@@ -2029,7 +2051,13 @@ Write the complete compiled report:"""
# _execute_tool_async_with_retry treats it as terminal.
if loop_guard is not None:
loop_guard.record(function_name, arguments, False, result=None)
return self._circuit_breaker_open_result(function_name)
return {
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
"circuit_open": True,
"agent_name": getattr(self, "name", None),
"session_id": getattr(self, "_session_id", None),
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
# Apply the per-agent tool timeout (ToolConfig.timeout) so the async
# path matches the sync path in tool_execution.py. asyncio.wait_for
@@ -2189,7 +2217,7 @@ Write the complete compiled report:"""
if isinstance(e, ToolExecutionError):
retryable = e.is_retryable
else:
retryable = not isinstance(e, _CALLER_ERROR_TYPES)
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.
@@ -630,27 +630,6 @@ class Handoff:
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
def _trace_handoff(self, source_agent: 'Agent', context: Optional[Dict[str, Any]], reason: Optional[str] = None) -> None:
"""Record the hand-off on the replay trace (no-op when tracing is off)."""
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled:
return
context = dict(context or {})
if reason is None:
reason = context.get("reason") or context.get("task") or context.get("prompt")
policy = getattr(self.config, "context_policy", None)
emitter.agent_handoff(
getattr(source_agent, "name", None) or "agent",
getattr(self.agent, "name", None),
reason=emitter._preview(reason) if reason is not None else None,
context_passed={
"context_policy": getattr(policy, "value", policy),
"args": context,
},
kind="handoff",
)
def _check_safety(self, source_agent: 'Agent') -> None:
"""
Check safety constraints before handoff.
@@ -799,7 +778,6 @@ class Handoff:
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
self._trace_handoff(source_agent, kwargs, reason=prompt)
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -910,7 +888,6 @@ class Handoff:
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
self._trace_handoff(source_agent, kwargs, reason=prompt)
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1039,7 +1016,6 @@ class Handoff:
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
self._trace_handoff(source_agent, kwargs, reason=None)
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1656,7 +1632,6 @@ class TypedHandoff(Handoff, Generic[T]):
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
self._trace_handoff(source_agent, kwargs, reason=None)
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1772,7 +1747,6 @@ class TypedHandoff(Handoff, Generic[T]):
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
self._trace_handoff(source_agent, kwargs, reason=None)
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -163,14 +163,10 @@ class ImageAgent(Agent):
# Gemini provider doesn't support response_format parameter
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
config.pop('response_format', None)
elif custom_llm_provider == "openai":
if config.get('response_format') == "url":
# "url" is OpenAI's default for DALL-E and is rejected by models that
# don't accept response_format (e.g. gpt-image), so don't send it
config.pop('response_format', None)
if 'dall-e-3' not in model_name.lower():
# Only dall-e-3 accepts 'style'; other OpenAI image models reject it
config.pop('style', None)
elif custom_llm_provider == "openai" and config.get('response_format') == "url":
# "url" is OpenAI's default for DALL-E and is rejected by models that
# don't accept response_format (e.g. gpt-image), so don't send it
config.pop('response_format', None)
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
@@ -38,26 +38,10 @@ _active_turn_cancel: "contextvars.ContextVar[Optional[tuple]]" = contextvars.Con
"praisonai_active_turn_cancel", default=None
)
# Exceptions a tool body raises about its *input* (bad value, missing key or
# path). They are terminal for the same arguments and say nothing about the
# tool's health, so they are never retried and never count toward the per-tool
# circuit breaker. Timeouts, connection errors and other OSErrors still do.
_CALLER_ERROR_TYPES = (
ValueError, TypeError, AttributeError, LookupError,
FileNotFoundError, FileExistsError, IsADirectoryError, NotADirectoryError, PermissionError,
)
# Worker count for the per-agent tool ThreadPoolExecutor. Used both when the
# pool is created and to decide (with the per-executor in-flight counter)
# whether a fresh call would have to queue behind hung workers. Matches the
# default ParallelToolCallExecutor width so a parallel batch never queues.
_TOOL_POOL_MAX_WORKERS = 5
# Upper bound on overflow ("spill") threads a single agent may run at once for
# timed async tool calls that find every pool worker busy. A hung spill thread
# cannot be reclaimed, so without this cap leaked threads would grow with
# concurrency instead of staying bounded (#5356).
_MAX_SPILL_TOOL_THREADS = 8
# whether a fresh call would have to queue behind hung workers.
_TOOL_POOL_MAX_WORKERS = 2
# 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).
@@ -1093,110 +1077,6 @@ class ToolExecutionMixin:
from .async_safety import DualLock
return self.__dict__.setdefault("_tool_executor_lock", DualLock())
def _submit_to_tool_pool(self, fn, *args, spill=False):
"""Submit ``fn(*args)`` to the bounded per-agent tool pool.
Shared by the sync and async tool paths so a timed-out tool never
strands a thread in the process-wide default executor. Returns
``(future, executor, orphaned)``; ``future`` is ``None`` when the pool
is exhausted by hung calls and the caller must refuse fast. With
``spill=True`` and every worker busy (but not orphan-capped), the call
runs immediately on a counted daemon thread instead of waiting for a
worker, and ``(future, None, orphaned)`` is returned; once
``_MAX_SPILL_TOOL_THREADS`` spill threads are still running the call is
refused like an exhausted pool.
"""
with self._get_tool_executor_lock().sync():
if getattr(self, '_tool_executor', None) is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=_TOOL_POOL_MAX_WORKERS,
thread_name_prefix=f"tool-{self.name}"
)
# Per-executor in-flight counter so a retired pool's
# late-finishing future can never corrupt the *new* pool's count.
self._tool_executor._inflight = 0
executor = self._tool_executor
orphaned = getattr(self, '_tool_executor_orphaned', 0)
inflight = getattr(executor, '_inflight', 0)
# Once the orphan cap is hit we can no longer retire a stuck pool,
# so we would otherwise queue behind hung workers and time out a
# healthy tool that never got to start. Refuse fast and visibly
# instead when no worker is free, and let the pool self-heal (the
# orphan count drops as hung threads finish).
if inflight >= _TOOL_POOL_MAX_WORKERS:
if orphaned >= _MAX_ORPHANED_TOOL_EXECUTORS:
return None, executor, orphaned
if spill:
spilled = getattr(self, '_tool_spill_inflight', 0)
if spilled >= _MAX_SPILL_TOOL_THREADS:
return None, executor, orphaned + spilled
self._tool_spill_inflight = spilled + 1
return self._start_spill_thread(fn, *args), None, orphaned
executor._inflight = inflight + 1
future = executor.submit(fn, *args)
def _on_tool_done(_f, _agent=self, _executor=executor): # noqa: ANN001
with _agent._get_tool_executor_lock().sync():
_executor._inflight = max(0, getattr(_executor, '_inflight', 1) - 1)
# Orphan accounting is per-POOL, not per-future: retiring a pool
# adds exactly one to the orphan count, so a pool may return
# exactly one slot. Recover it only when this retired pool's
# LAST in-flight worker exits (``_inflight`` == 0), and guard
# with a one-shot flag so a multi-worker pool can never
# decrement more than the single unit it contributed and steal
# another orphaned pool's slot.
if (
getattr(_agent, '_tool_executor', None) is not _executor
and _executor._inflight == 0
and not getattr(_executor, '_orphan_recovered', False)
):
_executor._orphan_recovered = True
_agent._tool_executor_orphaned = max(
0, getattr(_agent, '_tool_executor_orphaned', 0) - 1
)
future.add_done_callback(_on_tool_done)
return future, executor, orphaned
def _start_spill_thread(self, fn, *args):
"""Run ``fn(*args)`` on a daemon thread; caller already counted it."""
future = concurrent.futures.Future()
def _target():
try:
if future.set_running_or_notify_cancel():
try:
future.set_result(fn(*args))
except BaseException as e: # noqa: BLE001 - re-raised by awaiter
future.set_exception(e)
finally:
with self._get_tool_executor_lock().sync():
self._tool_spill_inflight = max(0, getattr(self, '_tool_spill_inflight', 1) - 1)
try:
threading.Thread(target=_target, daemon=True, name=f"tool-{self.name}-spill").start()
except BaseException:
self._tool_spill_inflight = max(0, getattr(self, '_tool_spill_inflight', 1) - 1)
raise
return future
def _retire_tool_executor(self, executor):
"""Retire a pool whose worker is stuck past its timeout.
The timed-out thread cannot be reclaimed. Retire this executor so the
next call gets a fresh worker instead of queuing behind a permanently
stuck one — but bound how many orphaned (still-blocked) pools we allow
to accumulate: once the cap is reached we stop recycling and keep
reusing the existing pool so repeated timeouts can't exhaust process
resources with an unbounded number of leaked threads.
"""
with self._get_tool_executor_lock().sync():
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if getattr(self, '_tool_executor', None) is executor and orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
def _get_turn_tools_lock(self):
"""Return the DualLock guarding the per-turn tool buffer.
@@ -1462,16 +1342,65 @@ class ToolExecutionMixin:
with tool_progress_channel(_attempt_sink):
# Use copy_context to preserve injection context in executor thread
ctx = contextvars.copy_context()
started = threading.Event()
def execute_with_context():
started.set()
with with_injection_context(state):
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
future, executor, orphaned = self._submit_to_tool_pool(ctx.run, execute_with_context)
if future is None:
pool_exhausted = False
with self._get_tool_executor_lock().sync():
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=_TOOL_POOL_MAX_WORKERS,
thread_name_prefix=f"tool-{self.name}"
)
# Per-executor in-flight counter so a
# retired pool's late-finishing future can
# never corrupt the *new* pool's count.
self._tool_executor._inflight = 0
executor = self._tool_executor
orphaned = getattr(self, '_tool_executor_orphaned', 0)
inflight = getattr(executor, '_inflight', 0)
# Once the orphan cap is hit we can no longer
# retire a stuck pool, so we would otherwise
# queue behind hung workers and time out a
# healthy tool that never got to start. Refuse
# fast and visibly instead when no worker is
# free, and let the pool self-heal (the orphan
# count drops as hung threads finish).
if orphaned >= _MAX_ORPHANED_TOOL_EXECUTORS and inflight >= _TOOL_POOL_MAX_WORKERS:
pool_exhausted = True
else:
executor._inflight = inflight + 1
future = executor.submit(ctx.run, execute_with_context)
def _on_tool_done(_f, _agent=self, _executor=executor): # noqa: ANN001
with _agent._get_tool_executor_lock().sync():
_executor._inflight = max(0, getattr(_executor, '_inflight', 1) - 1)
# Orphan accounting is per-POOL, not
# per-future: retiring a pool adds
# exactly one to the orphan count, so
# a pool may return exactly one slot.
# Recover it only when this retired
# pool's LAST in-flight worker exits
# (``_inflight`` == 0), and guard with
# a one-shot flag so a multi-worker
# pool can never decrement more than
# the single unit it contributed and
# steal another orphaned pool's slot.
if (
getattr(_agent, '_tool_executor', None) is not _executor
and _executor._inflight == 0
and not getattr(_executor, '_orphan_recovered', False)
):
_executor._orphan_recovered = True
_agent._tool_executor_orphaned = max(
0, getattr(_agent, '_tool_executor_orphaned', 0) - 1
)
future.add_done_callback(_on_tool_done)
if pool_exhausted:
logging.warning(
f"Tool worker pool exhausted; refusing to queue '{function_name}' "
f"behind {orphaned} hung tool call(s)."
@@ -1486,13 +1415,6 @@ class ToolExecutionMixin:
}
break
try:
# Charge tool_timeout from when the body starts,
# not from submit, so time spent queued for a
# worker never times out a healthy tool. The
# queue wait is itself bounded by tool_timeout
# so a pool stuck behind hung workers still fails.
if not started.wait(timeout=tool_timeout):
raise concurrent.futures.TimeoutError()
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
if _progress_active is not None:
@@ -1505,7 +1427,19 @@ class ToolExecutionMixin:
# 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}
self._retire_tool_executor(executor)
# The timed-out thread cannot be reclaimed. Retire this
# executor so the next call gets a fresh worker instead of
# queuing behind a permanently stuck one — but bound how many
# orphaned (still-blocked) threads we allow to accumulate:
# once the cap is reached we stop recycling and keep reusing
# the existing pool so repeated timeouts can't exhaust process
# resources with an unbounded number of leaked threads.
with self._get_tool_executor_lock().sync():
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if getattr(self, '_tool_executor', None) is executor and orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
@@ -1744,28 +1678,8 @@ class ToolExecutionMixin:
# `if artifact_ref:` guards remain valid on short-result paths.
artifact_ref = None
# ToolConfig.output_max_lines caps lines first, then the
# char limit below applies; output_direction picks the end(s) kept.
_tool_cfg = getattr(self, '_tool_config', None)
direction = getattr(_tool_cfg, 'output_direction', 'both')
max_lines = getattr(_tool_cfg, 'output_max_lines', None)
capped_str = result_str
if max_lines:
lines = result_str.split("\n")
if len(lines) > max_lines:
if direction == "head":
kept_head, kept_tail = lines[:max_lines], []
elif direction == "tail":
kept_head, kept_tail = [], lines[-max_lines:]
else:
n_tail = max_lines // 2
kept_head = lines[:max_lines - n_tail]
kept_tail = lines[-n_tail:] if n_tail else []
marker = f"...[{len(lines):,} lines total, showing {max_lines:,}]..."
capped_str = "\n".join(kept_head + [marker] + kept_tail)
# Check if we need to spill to artifact store
if len(capped_str) > limit or capped_str is not result_str:
if len(result_str) > limit:
# Try to use artifact store if available
if hasattr(self, '_artifact_store') and self._artifact_store is not None:
try:
@@ -1790,25 +1704,22 @@ class ToolExecutionMixin:
except Exception as e:
logging.debug(f"Failed to store artifact: {e}")
if len(capped_str) <= limit:
# Only the line cap applied
truncated = capped_str
else:
# Generate truncated preview
if direction == "head":
head, tail = capped_str[:limit], ""
elif direction == "tail":
head, tail = "", capped_str[-limit:]
else:
tail_size = min(limit // 5, 2000)
head = capped_str[:limit - tail_size]
tail = capped_str[-tail_size:] if tail_size > 0 else ""
total = "chars total" if artifact_ref else "chars"
truncated = f"{head}\n...[{len(result_str):,} {total}, showing first/last portions]...\n{tail}"
# Generate truncated preview
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
# If we stored an artifact, include reference in the output
if artifact_ref:
truncated = f"{truncated}\n\n{artifact_ref.to_inline()}"
truncated = (
f"{head}\n"
f"...[{len(result_str):,} chars total, showing first/last portions]...\n"
f"{tail}\n\n"
f"{artifact_ref.to_inline()}"
)
else:
# Fallback to simple truncation
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
else:
truncated = result_str
@@ -1826,15 +1737,10 @@ class ToolExecutionMixin:
else:
truncated = self._truncate_tool_output(function_name, truncated, tool_call_id)
# A triggered line cap applies even when its marker makes the
# preview no shorter than the original (many short lines).
line_capped = capped_str is not result_str
if line_capped or len(truncated) < len(result_str):
if len(truncated) < len(result_str):
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
# For dicts, truncate large string fields (e.g., raw_content from search).
# Field truncation keeps both ends, so a line cap or a head/tail
# direction uses the string preview instead.
if isinstance(result, dict) and direction == "both" and not line_capped:
# For dicts, truncate large string fields (e.g., raw_content from search)
if isinstance(result, dict):
max_field_chars = getattr(self, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
# Per-field truncation only shortens individual fields
@@ -2220,7 +2126,7 @@ class ToolExecutionMixin:
return response
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None, model: Optional[str] = None) -> float:
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
"""Calculate estimated cost for LLM call.
Uses litellm for accurate pricing (1000+ models) when available,
@@ -2230,7 +2136,6 @@ class ToolExecutionMixin:
prompt_tokens: Number of tokens in the prompt
completion_tokens: Number of tokens in the completion
response: Optional LLM response object for more accurate cost calculation
model: Model that answered (e.g. a fallback model); defaults to ``self.llm``
Returns:
Estimated cost in USD
@@ -2239,7 +2144,7 @@ class ToolExecutionMixin:
return calculate_llm_cost(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
model=model or self.llm,
model=self.llm,
response=response,
)
@@ -3322,28 +3227,23 @@ class ToolExecutionMixin:
return None
breaker_name = f"tool_{id(self)}_{function_name}"
config = CircuitBreakerConfig(
failure_threshold=5, # Open after 5 failures
recovery_timeout=60.0, # Wait 60s before trying half-open
graceful_degradation=True, # Return error instead of raising exception
failure_threshold=5,
recovery_timeout=60.0,
timeout=30.0,
graceful_degradation=True,
)
breaker = get_circuit_breaker(breaker_name, config)
# Ensure the registry entry is removed the moment this Agent is
# actually garbage-collected, regardless of whether close()/aclose()
# was ever called. This closes the CPython id-reuse window where a
# new Agent at the same address could inherit a stale OPEN breaker.
self._register_breaker_finalizer(breaker_name)
return breaker
def _circuit_breaker_open_result(self, function_name):
"""Return the standard ``circuit_open`` error dict for *function_name*."""
breaker = self._get_tool_circuit_breaker(function_name)
recovery = breaker.config.recovery_timeout if breaker is not None else 60.0
return {
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
"circuit_open": True,
"agent_name": getattr(self, "name", None),
"session_id": getattr(self, "_session_id", None),
"remediation": f"Wait for recovery_timeout ({recovery:g}s) or investigate recent tool failures.",
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
def _circuit_breaker_precheck(self, function_name):
@@ -3401,7 +3301,7 @@ class ToolExecutionMixin:
"""
# Import circuit breaker components first (lazy import for performance)
try:
from ..tools.circuit_breaker import CircuitBreakerException, _NeutralOutcome
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException, _NeutralOutcome
except ImportError:
# Circuit breaker not available - fallback to direct execution
logging.debug("Circuit breaker not available, falling back to direct tool execution")
@@ -3409,15 +3309,29 @@ class ToolExecutionMixin:
try:
# Get or create this agent's circuit breaker for this tool (shared
# with the async path so both use the same key and policy).
breaker = self._get_tool_circuit_breaker(function_name)
# Get or create circuit breaker for this tool.
# Scope the key to this Agent instance so one agent's failing tool
# cannot trip the breaker for another agent's same-named tool.
breaker_name = f"tool_{id(self)}_{function_name}"
config = CircuitBreakerConfig(
failure_threshold=5, # Open after 5 failures
recovery_timeout=60.0, # Wait 60s before trying half-open
timeout=30.0, # Tool call timeout
graceful_degradation=True # Return error instead of raising exception
)
breaker = get_circuit_breaker(breaker_name, config)
# Ensure the registry entry is removed the moment this Agent is
# actually garbage-collected, regardless of whether close()/aclose()
# was ever called. This closes the CPython id-reuse window where a
# new Agent at the same address could inherit a stale OPEN breaker.
self._register_breaker_finalizer(breaker_name)
# Execute tool through circuit breaker with failure detection wrapper
def _tool_wrapper():
result = self._execute_tool_impl(function_name, arguments)
# Caller errors (bad input: _CALLER_ERROR_TYPES, tagged
# non-retryable) say nothing about the tool's health: record
# Caller errors (bad input: ValueError/TypeError/AttributeError,
# tagged non-retryable) say nothing about the tool's health: record
# them as neither failure nor success, so they can neither open the
# breaker for other callers nor close a half-open one.
if isinstance(result, dict) and result.get("error") and \
@@ -3453,7 +3367,13 @@ class ToolExecutionMixin:
except CircuitBreakerException as e:
# Circuit breaker is open - return error dict instead of raising
logging.warning(f"Tool '{function_name}' circuit breaker open: {e}")
return self._circuit_breaker_open_result(function_name)
return {
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
"circuit_open": True,
"agent_name": getattr(self, "name", None),
"session_id": getattr(self, "_session_id", None),
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
def _find_declared_tool(self, function_name, tools=None):
"""Return the tool OBJECT named ``function_name``, or ``None``.
@@ -3903,7 +3823,7 @@ class ToolExecutionMixin:
# model and cannot collide with a tool's own "retryable" field.
return {
"error": error_msg,
"_praison_retryable": not isinstance(e, _CALLER_ERROR_TYPES),
"_praison_retryable": not isinstance(e, (ValueError, TypeError, AttributeError)),
}
# Unresolved: return a corrective, model-readable message so the model can
@@ -356,39 +356,6 @@ def _record_skipped_task(agents_instance, task):
task.status = "completed"
def _trace_task_begin(agents_instance, task):
"""Emit ``task_start``; returns a span for ``_trace_task_end`` (None when off)."""
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled:
return None
from ..workflows.diagram import task_node_id
node_id = task_node_id(agents_instance.tasks, task)
agent = getattr(getattr(task, "agent", None), "name", None)
emitter.task_start(
getattr(task, "name", None), getattr(task, "description", None),
agent=agent, node_id=node_id,
)
return emitter, node_id, agent, time.perf_counter()
def _trace_task_end(span, task, status):
"""Emit ``task_end`` (completed / failed / skipped) for a ``_trace_task_begin`` span."""
if span is None:
return
emitter, node_id, agent, started = span
result = getattr(task, "result", None)
emitter.task_end(
getattr(task, "name", None),
status,
retries=getattr(task, "retry_count", 0) or 0,
duration_ms=(time.perf_counter() - started) * 1000,
output=getattr(result, "raw", None) if result is not None else None,
agent=agent,
node_id=node_id,
)
def _task_should_skip(agents_instance, task):
"""Return True when a task's should_run gate evaluates falsy.
@@ -1572,19 +1539,7 @@ class AgentTeam(SpawnAnnounceProtocol):
agent_name=getattr(getattr(task, "agent", None), "name", None),
context={"kind": "task_output_review", "prompt": prompt},
)
from ..trace.context_events import _approval_begin, _approval_end, get_context_emitter
_approval = None
if get_context_emitter()._enabled:
from ..workflows.diagram import task_node_id
_approval = _approval_begin(
request, kind="task_output_review", node_id=task_node_id(self.tasks, task),
)
try:
decision = backend.request_approval_sync(request)
except BaseException as exc:
_approval_end(_approval, error=exc)
raise
_approval_end(_approval, decision)
decision = backend.request_approval_sync(request)
if getattr(decision, "approved", False):
return task_output, False
@@ -1729,21 +1684,10 @@ class AgentTeam(SpawnAnnounceProtocol):
except Exception as e:
logger.error(f"Error in on_task_start callback: {e}")
self._propagate_variables(task, task_id)
def _propagate_variables(self, task, task_id):
"""Apply global variables to a task that has none of its own.
Uses a shallow copy so a task mutating its variables doesn't leak back
into the shared AgentTeam state. The copy is remembered so a later fresh
``start()`` can drop it (and re-propagate current values) without
touching caller-defined ``Task.variables``.
"""
# Apply global variables to task if not already set. Use a shallow copy so a
# task mutating its variables doesn't leak back into the shared AgentTeam state.
if self.variables and not getattr(task, 'variables', None):
task.variables = self.variables.copy()
if not hasattr(self, "_propagated_variables"):
self._propagated_variables = {}
self._propagated_variables[task_id] = task.variables
def _run_task_complete_hook(self, task, task_output):
"""Run the on_task_complete hook. Shared by run_task and arun_task."""
@@ -1770,7 +1714,8 @@ class AgentTeam(SpawnAnnounceProtocol):
except Exception as e:
logger.error(f"Error in on_task_start callback: {e}")
self._propagate_variables(task, task_id)
if self.variables and not getattr(task, 'variables', None):
task.variables = self.variables.copy()
async def _arun_task_complete_hook(self, task, task_output):
"""Async-aware on_task_complete hook for arun_task (see _arun_task_start_hook)."""
@@ -1795,18 +1740,6 @@ class AgentTeam(SpawnAnnounceProtocol):
logger.info(f"Task with ID {task_id} is already completed")
return
_span = _trace_task_begin(self, task)
_status = "failed"
try:
if await self._arun_task_body(task_id, task) == "skipped":
_status = "skipped"
elif task.status == "completed":
_status = "completed"
finally:
_trace_task_end(_span, task, _status)
async def _arun_task_body(self, task_id, task):
"""Body of :meth:`arun_task`; returns ``"skipped"`` for a gated-out task."""
# Call on_task_start callback and propagate variables (async-aware, mirrors run_task)
await self._arun_task_start_hook(task, task_id)
@@ -1814,7 +1747,7 @@ class AgentTeam(SpawnAnnounceProtocol):
if await _atask_should_skip(self, task):
logger.info(f"Task {task_id} skipped by should_run gate")
_record_skipped_task(self, task)
return "skipped"
return
# Use per-task max_retries if available
task_max = getattr(task, "max_retries", self.max_retries)
@@ -1994,20 +1927,18 @@ class AgentTeam(SpawnAnnounceProtocol):
if self._depends_on_pending(task, tasks_to_run):
await self._gather_with_isolation([c for _, c in tasks_to_run])
tasks_to_run = []
# The predecessor may have run synchronously (no batch),
# or may just have finished in the batch flushed above.
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
task.status = "failed"
continue
# A just-flushed prerequisite may now be failed; don't
# queue a dependent that would run with missing context
# unless it opted into graceful degradation.
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(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([c for _, c in tasks_to_run])
tasks_to_run = []
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
task.status = "failed"
continue
# Run sync task in an executor to avoid blocking the event loop
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
@@ -2270,16 +2201,7 @@ class AgentTeam(SpawnAnnounceProtocol):
# Re-entrant on the owning thread so batch runs (astart_for_each) can hold
# the guard across the whole batch while still calling astart() per item.
with self._guard_execution():
self._reset_if_previous_run_finished()
try:
from ..trace.context_events import _workflow_scope
with _workflow_scope("team", self._replay_name(), self.process,
graph_fn=self.to_graph, run_id=self.run_id) as _run:
result = await self._astart_impl(content=content, return_dict=return_dict, **kwargs)
self._mark_run_status(_run)
return result
finally:
self._previous_run_finished = True
return await self._astart_impl(content=content, return_dict=return_dict, **kwargs)
async def _astart_impl(self, content=None, return_dict=False, **kwargs):
# Track execution via telemetry
@@ -2293,7 +2215,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if not task.context:
task.context = []
task.context.append(content)
self._injected_content.append((task, content))
if self.planning:
await self._arun_with_planning()
@@ -2332,11 +2253,13 @@ class AgentTeam(SpawnAnnounceProtocol):
def save_output_to_file(self, task, task_output):
if task.output_file:
try:
from ..utils.output_file import write_output_file
saved = write_output_file(
task.output_file, str(task_output), create_dirs=task.create_directory
)
if saved and self.verbose >= 1:
if task.create_directory:
dir_name = os.path.dirname(task.output_file)
if dir_name:
os.makedirs(dir_name, exist_ok=True)
with open(task.output_file, "w") as f:
f.write(str(task_output))
if self.verbose >= 1:
logger.info(f"Task output saved to {task.output_file}")
except Exception as e:
display_error(f"Error saving task output to file: {e}")
@@ -2395,18 +2318,6 @@ class AgentTeam(SpawnAnnounceProtocol):
logger.info(f"Task with ID {task_id} is already completed")
return
_span = _trace_task_begin(self, task)
_status = "failed"
try:
if self._run_task_body(task_id, task) == "skipped":
_status = "skipped"
elif task.status == "completed":
_status = "completed"
finally:
_trace_task_end(_span, task, _status)
def _run_task_body(self, task_id, task):
"""Body of :meth:`run_task`; returns ``"skipped"`` for a gated-out task."""
# Call on_task_start callback and propagate variables (shared with arun_task)
self._run_task_start_hook(task, task_id)
@@ -2414,7 +2325,7 @@ class AgentTeam(SpawnAnnounceProtocol):
if _task_should_skip(self, task):
logger.info(f"Task {task_id} skipped by should_run gate")
_record_skipped_task(self, task)
return "skipped"
return
# Use per-task max_retries if available
task_max = getattr(task, "max_retries", self.max_retries)
@@ -2506,9 +2417,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if self.process == "workflow":
for task_id in process.workflow():
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
self.tasks[task_id].status = "failed"
continue
self.run_task(task_id)
elif self.process == "sequential":
for task_id in process.sequential():
@@ -2533,21 +2441,6 @@ class AgentTeam(SpawnAnnounceProtocol):
except StopIteration:
pass
def to_graph(self) -> Dict[str, Any]:
"""This team's definition as graph data (``praisonai.graph/1``).
Covers agents, tasks (context/depends_on, next_tasks, conditions),
tools, hand-offs, human review and the hierarchical manager. Node ids
are stable across processes and match replay events' ``node_id``.
"""
from ..workflows.diagram import to_graph
return to_graph(self)
def to_mermaid(self) -> str:
"""Render this team's definition as a mermaid diagram."""
from ..workflows.diagram import graph_to_mermaid, to_graph
return graph_to_mermaid(to_graph(self))
def get_task_status(self, task_id):
if task_id in self.tasks:
return self.tasks[task_id].status
@@ -2626,31 +2519,8 @@ class AgentTeam(SpawnAnnounceProtocol):
# Re-entrant on the owning thread so batch runs (start_for_each) can hold
# the guard across the whole batch while still calling start() per item.
with self._guard_execution():
self._reset_if_previous_run_finished()
try:
from ..trace.context_events import _workflow_scope
with _workflow_scope("team", self._replay_name(), self.process,
graph_fn=self.to_graph, run_id=self.run_id) as _run:
result = self._start_impl(content=content, return_dict=return_dict,
output=output, **kwargs)
self._mark_run_status(_run)
return result
finally:
self._previous_run_finished = True
def _replay_name(self) -> str:
"""Run label for Replay: the team's name, else its agents joined ("A + B")."""
if self.name:
return self.name
names = [getattr(a, "name", None) or "Agent" for a in (self.agents or [])]
return " + ".join(names) if names else "Agent team"
def _mark_run_status(self, run_state):
"""Record a failed replay run when any task ended failed (tracing on only)."""
if run_state is not None and any(
getattr(t, "status", None) == "failed" for t in self.tasks.values()
):
run_state["status"] = "failed"
return self._start_impl(content=content, return_dict=return_dict,
output=output, **kwargs)
def _start_impl(self, content=None, return_dict=False, output=None, **kwargs):
# Track execution via telemetry
@@ -2681,7 +2551,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if not task.context:
task.context = []
task.context.append(content)
self._injected_content.append((task, content))
# ─────────────────────────────────────────────────────────────
# Verbose Mode: Show Rich panels for multi-agent workflow
@@ -2877,19 +2746,14 @@ class AgentTeam(SpawnAnnounceProtocol):
Captures the fields that ``run_task``/``arun_task`` mutate during a run
(``status``, ``result``, ``retry_count``) plus caller-defined
``variables``, keeping the public Task API backward-compatible.
``propagated`` records whether those variables were copied from the
team's ``variables`` so a restore keeps that ownership tracking.
"""
snapshot = {}
propagated = getattr(self, "_propagated_variables", {})
for task_id, task in getattr(self, "tasks", {}).items():
variables = getattr(task, "variables", None)
snapshot[task_id] = {
"status": getattr(task, "status", None),
"result": getattr(task, "result", None),
"retry_count": getattr(task, "retry_count", None),
"variables": variables,
"propagated": variables is not None and propagated.get(task_id) is variables,
"variables": getattr(task, "variables", None),
}
return snapshot
@@ -2908,41 +2772,8 @@ class AgentTeam(SpawnAnnounceProtocol):
task.retry_count = 0
task.variables = {}
def _reset_if_previous_run_finished(self):
"""Make a repeated ``start()``/``astart()`` a fresh run.
``run_task``/``arun_task`` skip tasks whose status is "completed", so
without this a second ``start()`` on the same team executed nothing and
returned the previous run's output. Resetting only once a previous run
has finished keeps ``restore_session_state()`` + ``start()`` resuming,
and also drops the ``content`` earlier runs appended to task context.
Caller-defined ``Task.variables`` are kept; only copies propagated from
the team's ``variables`` are cleared so current values re-propagate.
"""
injected = getattr(self, "_injected_content", [])
if getattr(self, "_previous_run_finished", False):
propagated = getattr(self, "_propagated_variables", {})
for task_id, task in self.tasks.items():
task.status = "not started"
task.result = None
if hasattr(task, "retry_count"):
task.retry_count = 0
if task_id in propagated and getattr(task, "variables", None) is propagated[task_id]:
task.variables = {}
propagated.clear()
for task, item in injected:
try:
task.context.remove(item)
except (AttributeError, ValueError):
pass
injected = []
# A resumed run keeps the tracked entries so the next fresh run removes them.
self._previous_run_finished = False
self._injected_content = injected
def _restore_task_state(self, snapshot):
"""Restore per-task run state captured by ``_snapshot_task_state``."""
propagated = {}
for task_id, state in snapshot.items():
task = self.tasks.get(task_id)
if task is None:
@@ -2952,9 +2783,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if state["retry_count"] is not None:
task.retry_count = state["retry_count"]
task.variables = state["variables"]
if state.get("propagated"):
propagated[task_id] = task.variables
self._propagated_variables = propagated
def _build_batch_result(self, batch_id, items):
"""Aggregate per-item results into a batch summary dict.
@@ -3303,9 +3131,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if state.get("variables"):
task.variables = state["variables"]
restored += 1
if restored:
# A restored checkpoint is meant to be resumed, not reset.
self._previous_run_finished = False
return restored
@staticmethod
@@ -4636,10 +4461,7 @@ class AgentTeam(SpawnAnnounceProtocol):
# Read target agents inside lock to avoid race condition
with self._spawn_lock:
target_agents = agent_ids or list(self._spawned_agents.keys())
if not target_agents:
# Return retained history only for the default target selection.
return list(self._completion_events) if agent_ids is None else []
completed_agents = set()
while True:
@@ -44,9 +44,6 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
"acp_edit_file": "high",
"acp_delete_file": "high",
"execute_query": "high",
"github_create_branch": "high",
"github_commit_and_push": "high",
"github_create_pull_request": "high",
"evaluate": "medium",
"crawl": "medium",
"scrape_page": "medium",
@@ -79,11 +76,10 @@ DOOM_LOOP_TARGET = "__doom_loop__"
# or set ``PRAISONAI_TOOL_SAFETY=off``; users who want stricter
# controls can opt into ``approval="safe"`` or ``"read_only"``.
PERMISSION_PRESETS = {
# "default" — blocks delete + shell/code exec + git push/PR. Allows read/create/edit.
# "default" — blocks delete + shell/code exec. Allows read/create/edit.
"default": frozenset({
"execute_command", "kill_process", "execute_code", "acp_execute_command",
"delete_file", "move_file", "copy_file", "acp_delete_file",
"github_create_branch", "github_commit_and_push", "github_create_pull_request",
}),
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
@@ -693,41 +689,29 @@ class ApprovalRegistry:
liveness=liveness,
)
# Replay: a person is actually being asked (the fast paths above are not).
from ..trace.context_events import _approval_begin, _approval_end
_trace = _approval_begin(request)
try:
with _human_wait_scope():
# Prefer sync method if available
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Use shared utility for consistent async-to-sync bridging
from .utils import run_coroutine_safely
decision = run_coroutine_safely(
backend.request_approval(request),
timeout=self.timeout
)
with _human_wait_scope():
# Prefer sync method if available
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Use shared utility for consistent async-to-sync bridging
from .utils import run_coroutine_safely
decision = run_coroutine_safely(
backend.request_approval(request),
timeout=self.timeout
)
# Live-authority gate: a resolution that lands after the originating
# turn was stopped/superseded is dropped fail-closed — the tool does not
# run and no durable session/always grant is persisted.
stale = self._reject_if_stale(request)
if stale is not None:
decision = stale
return stale
# Live-authority gate: a resolution that lands after the originating
# turn was stopped/superseded is dropped fail-closed — the tool does not
# run and no durable session/always grant is persisted.
stale = self._reject_if_stale(request)
if stale is not None:
return stale
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
except BaseException as exc:
_approval_end(_trace, error=exc)
_trace = None
raise
finally:
if _trace is not None:
_approval_end(_trace, decision)
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
async def approve_async(
self,
@@ -780,38 +764,26 @@ class ApprovalRegistry:
liveness=liveness,
)
# Replay: a person is actually being asked (the fast paths above are not).
from ..trace.context_events import _approval_begin, _approval_end
_trace = _approval_begin(request)
try:
try:
async with _human_wait_scope():
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=self.timeout,
)
except asyncio.TimeoutError:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
async with _human_wait_scope():
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=self.timeout,
)
except asyncio.TimeoutError:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
# Live-authority gate: a resolution that lands after the originating
# turn was stopped/superseded is dropped fail-closed — the tool does not
# run and no durable session/always grant is persisted.
stale = self._reject_if_stale(request)
if stale is not None:
decision = stale
return stale
# Live-authority gate: a resolution that lands after the originating
# turn was stopped/superseded is dropped fail-closed — the tool does not
# run and no durable session/always grant is persisted.
stale = self._reject_if_stale(request)
if stale is not None:
return stale
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
except BaseException as exc:
_approval_end(_trace, error=exc)
_trace = None
raise
finally:
if _trace is not None:
_approval_end(_trace, decision)
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
@staticmethod
def _reject_if_stale(request: ApprovalRequest) -> Optional[ApprovalDecision]:
@@ -590,11 +590,8 @@ class GuardrailConfig:
# With validator function
Agent(guardrails=my_validator_fn)
# With a string: an LLM-validation prompt (not a preset name)
Agent(guardrails="Ensure the response is polite")
# With a preset (sets max_retries only) plus a validator
Agent(guardrails=["strict", {"validator": my_validator_fn}])
# With string preset
Agent(guardrails="strict") # Uses strict preset
# For tool policy enforcement, use the dedicated `policy` param instead:
Agent(policy=PolicyEngine(...)) # policy strings on guardrails are NOT enforced
@@ -603,7 +600,7 @@ class GuardrailConfig:
Agent(guardrails=GuardrailConfig(
validator=my_validator_fn,
max_retries=3,
on_fail="retry", # only "retry" is implemented
on_fail="retry",
))
# With LLM-based validation
@@ -912,8 +909,7 @@ class ExecutionConfig:
# ``rate_limiter`` explicitly for burst/TPM control (it takes precedence).
max_rpm: Optional[int] = None
# Time limits: per-turn wall-clock seconds for the loop guard.
# None = loop-guard default (600s); 0 = no time limit.
# Time limits
max_execution_time: Optional[int] = None
# Retry settings
@@ -275,11 +275,9 @@ REFLECTION_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
GUARDRAIL_PRESETS: Dict[str, Dict[str, Any]] = {
# Presets only set retry counts; supply a validator alongside them,
# e.g. guardrails=["strict", {"validator": fn}].
"strict": {"max_retries": 5},
"permissive": {"max_retries": 1},
"safety": {"max_retries": 3},
"strict": {"max_retries": 5, "on_fail": "raise"},
"permissive": {"max_retries": 1, "on_fail": "skip"},
"safety": {"max_retries": 3, "on_fail": "retry"},
}
@@ -107,8 +107,7 @@ class LoopGuardConfig:
mutating_halt_threshold: int = 7
# Time-based limits
# Max seconds per chat turn; ExecutionConfig.max_execution_time overrides (0 = no limit there).
max_time_per_turn: float = 600.0
max_time_per_turn: float = 120.0 # Max seconds per chat turn
# No-progress detection
no_progress_warn: int = 4
@@ -0,0 +1,147 @@
"""
Utility functions for PraisonAI Agents evaluation framework.
"""
import json
from praisonaiagents._logging import get_logger
from pathlib import Path
from typing import Any, Dict, Optional, Union
logger = get_logger(__name__)
def save_result_to_file(
result: Any,
file_path: str,
eval_id: Optional[str] = None,
name: Optional[str] = None
) -> bool:
"""
Save evaluation result to a file.
Args:
result: The evaluation result to save
file_path: Path to save the result (supports {name}, {eval_id} placeholders)
eval_id: Optional eval ID for placeholder substitution
name: Optional name for placeholder substitution
Returns:
True if save was successful, False otherwise
"""
try:
formatted_path = file_path.format(name=name or "", eval_id=eval_id or "")
path = Path(formatted_path)
path.parent.mkdir(parents=True, exist_ok=True)
if hasattr(result, 'to_dict'):
data = result.to_dict()
elif hasattr(result, '__dict__'):
data = result.__dict__
else:
data = {"result": str(result)}
path.write_text(json.dumps(data, indent=2, default=str))
logger.debug(f"Saved result to: {formatted_path}")
return True
except Exception as e:
logger.warning(f"Failed to save result: {e}")
return False
def load_result_from_file(file_path: str) -> Optional[Dict[str, Any]]:
"""
Load evaluation result from a file.
Args:
file_path: Path to the result file
Returns:
Dictionary containing the result data, or None if load failed
"""
try:
path = Path(file_path)
if not path.exists():
logger.warning(f"Result file not found: {file_path}")
return None
data = json.loads(path.read_text())
return data
except Exception as e:
logger.warning(f"Failed to load result: {e}")
return None
def format_score(score: float, max_score: float = 10.0) -> str:
"""
Format a score for display.
Args:
score: The score value
max_score: Maximum possible score
Returns:
Formatted score string
"""
return f"{score:.1f}/{max_score:.0f}"
def format_percentage(value: float) -> str:
"""
Format a value as a percentage.
Args:
value: Value between 0 and 1
Returns:
Formatted percentage string
"""
return f"{value * 100:.1f}%"
def format_duration(seconds: float) -> str:
"""
Format a duration in seconds for display.
Args:
seconds: Duration in seconds
Returns:
Formatted duration string
"""
if seconds < 0.001:
return f"{seconds * 1000000:.0f}μs"
elif seconds < 1:
return f"{seconds * 1000:.2f}ms"
elif seconds < 60:
return f"{seconds:.2f}s"
else:
minutes = int(seconds // 60)
secs = seconds % 60
return f"{minutes}m {secs:.1f}s"
def format_memory(mb: float) -> str:
"""
Format memory usage for display.
Args:
mb: Memory in megabytes
Returns:
Formatted memory string
"""
if mb < 1:
return f"{mb * 1024:.1f} KB"
elif mb < 1024:
return f"{mb:.2f} MB"
else:
return f"{mb / 1024:.2f} GB"
def calculate_pass_rate(passed: int, total: int) -> float:
"""
Calculate pass rate.
Args:
passed: Number of passed evaluations
total: Total number of evaluations
Returns:
Pass rate as a float between 0 and 1
"""
if total == 0:
return 0.0
return passed / total
@@ -11,7 +11,6 @@ import time
import asyncio
from praisonaiagents._logging import get_logger
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional, Dict, Any
@@ -79,14 +78,25 @@ class HookRunner:
# misbehaving hook can only exhaust hook capacity, never the loop's
# shared default executor that guardrails/tools/locks also rely on.
self._hook_pool: Optional[ThreadPoolExecutor] = None
# hook.id -> worker Future of a sync hook that ran past its timeout.
# It is cleared only when that worker finishes, so a stuck hook caps
# new launches at one leaked thread per hook. Healthy concurrent calls
# (e.g. parallel tool calls) are not tracked and never denied. A
# threading lock, because execute_sync() drives each call on its own
# event loop in the caller's thread.
# hook.id -> in-flight Future for a sync hook. The slot is reserved
# before submission and only cleared when the worker finishes, so a
# still-running (timed-out or cancelled) hook caps new launches at one
# thread per hook. Guarded by ``_stuck_lock`` so concurrent invocations
# of the same hook cannot both reserve the slot and race past the cap.
self._stuck: Dict[str, Any] = {}
self._stuck_lock = threading.Lock()
self._stuck_lock_obj: Optional[asyncio.Lock] = None
@property
def _stuck_lock(self) -> asyncio.Lock:
"""Lazily create the in-flight-slot lock on first use.
Created lazily (not in ``__init__``) because a ``HookRunner`` may be
constructed before any event loop exists; the lock is only needed once
we are already inside ``async def execute`` / ``_execute_function_hook``.
"""
if self._stuck_lock_obj is None:
self._stuck_lock_obj = asyncio.Lock()
return self._stuck_lock_obj
def _release_stuck(self, hook_id: str, fut: Any) -> None:
"""Clear the in-flight slot once the worker Future completes.
@@ -94,9 +104,8 @@ class HookRunner:
Only removes the entry if it still points at *this* Future, so a newer
reserved invocation is never clobbered by an older worker draining.
"""
with self._stuck_lock:
if self._stuck.get(hook_id) is fut:
self._stuck.pop(hook_id, None)
if self._stuck.get(hook_id) is fut:
self._stuck.pop(hook_id, None)
def _get_hook_pool(self) -> ThreadPoolExecutor:
"""Lazily create the dedicated sync-hook executor."""
@@ -430,9 +439,14 @@ class HookRunner:
timeout=timeout
)
else:
# A previous invocation that timed out and is still running
# fails this one closed instead of leaking another thread.
with self._stuck_lock:
# Reserve the single in-flight slot for this hook *under a lock*
# and *before* submitting, so two concurrent invocations can't
# both see an empty ``_stuck`` and both launch a worker (which
# would exceed the intended one-worker-per-hook cap and double
# the side effects). The slot is only cleared once the worker
# finishes; a still-running previous worker fails this one
# closed immediately instead of leaking another thread.
async with self._stuck_lock:
prev = self._stuck.get(hook.id)
if prev is not None and not prev.done():
duration = (time.time() - start_time) * 1000
@@ -449,31 +463,29 @@ class HookRunner:
),
)
# Execute sync function in the dedicated hook pool with
# timeout. ``copy_context_to_callable`` preserves
# trace/session contextvars across the thread, matching
# other executor sites. A concurrent Future (not a loop-bound
# one) so the stuck-slot release works after the loop closes.
from ..trace.context_events import copy_context_to_callable
func = hook.func
data = input_data
fut = self._get_hook_pool().submit(
copy_context_to_callable(lambda: func(data))
)
try:
result = await asyncio.wait_for(
asyncio.shield(asyncio.wrap_future(fut)), timeout=timeout
# Execute sync function in the dedicated hook pool with
# timeout. ``copy_context_to_callable`` preserves
# trace/session contextvars across the thread, matching
# other executor sites.
from ..trace.context_events import copy_context_to_callable
loop = asyncio.get_event_loop()
func = hook.func
data = input_data
fut = loop.run_in_executor(
self._get_hook_pool(),
copy_context_to_callable(lambda: func(data)),
)
except (asyncio.TimeoutError, asyncio.CancelledError):
# The worker cannot be cancelled; hold the slot until it
# actually finishes.
if not fut.done():
with self._stuck_lock:
self._stuck[hook.id] = fut
fut.add_done_callback(
lambda f, hid=hook.id: self._release_stuck(hid, f)
)
raise
# Reserve the slot now; a completion callback clears it only
# when the worker actually finishes, so a timeout *or* a
# cancellation leaves the slot held until the thread drains.
self._stuck[hook.id] = fut
fut.add_done_callback(
lambda f, hid=hook.id: self._release_stuck(hid, f)
)
result = await asyncio.wait_for(
asyncio.shield(fut), timeout=timeout
)
duration = (time.time() - start_time) * 1000
@@ -281,8 +281,6 @@ class SubQuestionEngine:
# Retrieve for each sub-question
try:
results = retriever.retrieve(sub_q, top_k=3)
# SmartRetriever returns a RetrievalResult, not a list.
results = getattr(results, "chunks", results)
for r in results:
if hasattr(r, 'text'):
all_contexts.append(r.text)
+25 -289
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
import time
import json
import xml.etree.ElementTree as ET
from ..errors import AgentErrorKind, BudgetExceededError, FailoverDecision, IdleTimeoutBreaker, ToolExecutionError
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker, ToolExecutionError
from ..model_harness.guard import check_model_request
# Gap 2: Tool call execution imports
from ..tools.call_executor import ToolCall, create_tool_call_executor
@@ -43,165 +43,6 @@ _TOOL_ARGUMENTS_PARSE_FAILED = object()
# Bounded shrink-and-retry steps after a provider context-overflow rejection.
_CONTEXT_OVERFLOW_SHRINK_STEPS = 3
# ---------------------------------------------------------------------------
# Replay llm_request / llm_response for the LiteLLM path. The agent's
# OpenAI-client path records its own pair around the whole dispatch (which can
# reach this class through the LiteLLM adapter); ``_llm_call_open`` makes sure
# that call is not counted twice. A no-op returning None when tracing is off.
# ---------------------------------------------------------------------------
def _replay_llm_begin(llm: Any, params: Dict[str, Any]):
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled or emitter._llm_call_open():
return None
agent = getattr(llm, "current_agent_name", None) or emitter._current_agent()
model = params.get("model") or getattr(llm, "model", None)
# Chat Completions sends ``messages``; the Responses API sends ``input``.
messages = params.get("messages", params.get("input"))
# Copy: the caller keeps appending turns to (and shrinking) the live list.
messages = list(messages) if isinstance(messages, list) else None
emitter._emit(emitter._llm_request_event(
agent, len(messages) if messages else 0, 0, model=model, messages=messages,
))
return {"emitter": emitter, "agent": agent, "model": model,
"started": time.perf_counter(), "content": [], "usage": None, "finish": None}
def _replay_usage_tokens(usage: Any) -> tuple:
if not usage:
return 0, 0
get = usage.get if isinstance(usage, dict) else (lambda k, d=None: getattr(usage, k, d))
# Chat Completions names, then the Responses API names.
prompt = get("prompt_tokens", None) or get("input_tokens", 0)
completion = get("completion_tokens", None) or get("output_tokens", 0)
return int(prompt or 0), int(completion or 0)
def _replay_observe(span: Dict[str, Any], response: Any, chunk: bool) -> None:
"""Collect content / finish reason / usage from a response or a stream chunk."""
try:
get = response.get if isinstance(response, dict) else (lambda k, d=None: getattr(response, k, d))
usage = get("usage", None)
if usage:
span["usage"] = usage
# The model that actually served the call (a failover profile may
# differ from the requested one), so name and price match it.
served = get("model", None)
if isinstance(served, str) and served:
span["model"] = served
choices = get("choices", None) or []
if not choices:
return
first = choices[0]
fget = first.get if isinstance(first, dict) else (lambda k, d=None: getattr(first, k, d))
if fget("finish_reason", None):
span["finish"] = fget("finish_reason", None)
body = fget("delta" if chunk else "message", None)
if body is not None:
content = body.get("content") if isinstance(body, dict) else getattr(body, "content", None)
if isinstance(content, str) and content:
span["content"].append(content)
except Exception:
pass # Tracing must never break a model call.
def _replay_llm_end(span: Optional[Dict[str, Any]], error: Optional[BaseException] = None) -> None:
if span is None or span.get("done"):
return
span["done"] = True
emitter = span["emitter"]
prompt_tokens, completion_tokens = _replay_usage_tokens(span.get("usage"))
cost = 0.0
if prompt_tokens or completion_tokens:
try:
import litellm
prompt_cost, completion_cost = litellm.cost_per_token(
model=span["model"], prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
)
cost = float(prompt_cost or 0) + float(completion_cost or 0)
except Exception:
cost = 0.0 # Unknown price: leave it unknown rather than guess.
emitter._emit(emitter._llm_response_event(
span["agent"],
duration_ms=(time.perf_counter() - span["started"]) * 1000,
finish_reason="error" if error is not None else span.get("finish"),
response_content=str(error) if error is not None else "".join(span["content"]),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cost_usd=cost,
model=span["model"],
))
def _replay_stream_result(span: Dict[str, Any], result: Any) -> None:
"""Finish a span from a streamed Responses API ``(text, tools, final)`` tuple."""
try:
text, final = result[0], result[2] if len(result) > 2 else None
if isinstance(text, str) and text:
span["content"].append(text)
if final is not None:
span["usage"] = getattr(final, "usage", None)
except Exception:
pass
_replay_llm_end(span)
class _ReplayStream:
"""Pass-through for a LiteLLM stream that records ``llm_response`` when it ends."""
__slots__ = ("_stream", "_iter", "_span")
def __init__(self, stream: Any, span: Dict[str, Any]) -> None:
self._stream = stream
self._iter = None
self._span = span
def __iter__(self):
return self
def __next__(self):
if self._iter is None:
self._iter = iter(self._stream)
try:
chunk = next(self._iter)
except StopIteration:
_replay_llm_end(self._span)
raise
except BaseException as exc:
_replay_llm_end(self._span, exc)
raise
_replay_observe(self._span, chunk, chunk=True)
return chunk
def __aiter__(self):
return self
async def __anext__(self):
if self._iter is None:
self._iter = self._stream.__aiter__()
try:
chunk = await self._iter.__anext__()
except StopAsyncIteration:
_replay_llm_end(self._span)
raise
except BaseException as exc:
_replay_llm_end(self._span, exc)
raise
_replay_observe(self._span, chunk, chunk=True)
return chunk
def close(self):
_replay_llm_end(self._span)
close = getattr(self._stream, "close", None)
if callable(close):
return close()
def __getattr__(self, name: str) -> Any:
return getattr(self._stream, name)
# Default per-request timeout (seconds) when ``timeout`` is not passed. Without
# it litellm falls back to its 6000s ceiling, so a provider that accepts the
# connection but never answers blocks the turn for ~100 minutes per attempt.
@@ -344,11 +185,6 @@ except ImportError:
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
from ..tools.hosted import is_hosted_tool # noqa: E402
# ``(llm, callback)`` bound by the owning Agent for one custom-LLM turn. Every
# tracked call made by that ``llm`` reports its response to ``callback`` so the
# Agent's cost/token counters and ``max_budget`` cover the whole tool loop.
_usage_sink = contextvars.ContextVar("praisonai_llm_usage_sink", default=None)
class LLMResponseError(Exception):
"""Raised when the LLM tool-calling loop fails and cannot produce a response.
@@ -1900,25 +1736,15 @@ Respond with ONLY a valid JSON tool call in this format:
# entirely, so scripted turns never reach (or trip) the guard.
check_model_request(self.model, "litellm.completion")
import litellm
_replay = _replay_llm_begin(self, completion_params)
try:
for step in range(_CONTEXT_OVERFLOW_SHRINK_STEPS + 1):
try:
response = self._call_with_retry(litellm.completion, **completion_params)
break
except Exception as e:
if not self._shrink_on_context_overflow(e, completion_params, step):
raise
except BaseException as exc:
_replay_llm_end(_replay, exc)
raise
for step in range(_CONTEXT_OVERFLOW_SHRINK_STEPS + 1):
try:
response = self._call_with_retry(litellm.completion, **completion_params)
break
except Exception as e:
if not self._shrink_on_context_overflow(e, completion_params, step):
raise
if not completion_params.get("stream"):
self._track_token_usage(response, self._response_model_for_tracking(response))
if _replay is not None:
_replay_observe(_replay, response, chunk=False)
_replay_llm_end(_replay)
elif _replay is not None:
response = _ReplayStream(response, _replay)
return response
async def _acompletion_with_retry(self, **completion_params):
@@ -1939,28 +1765,18 @@ Respond with ONLY a valid JSON tool call in this format:
"""
check_model_request(self.model, "litellm.acompletion")
import litellm
_replay = _replay_llm_begin(self, completion_params)
try:
for step in range(_CONTEXT_OVERFLOW_SHRINK_STEPS + 1):
try:
response = await self._call_with_retry_async(
litellm.acompletion,
**completion_params,
)
break
except Exception as e:
if not self._shrink_on_context_overflow(e, completion_params, step):
raise
except BaseException as exc:
_replay_llm_end(_replay, exc)
raise
for step in range(_CONTEXT_OVERFLOW_SHRINK_STEPS + 1):
try:
response = await self._call_with_retry_async(
litellm.acompletion,
**completion_params,
)
break
except Exception as e:
if not self._shrink_on_context_overflow(e, completion_params, step):
raise
if not completion_params.get("stream"):
self._track_token_usage(response, self._response_model_for_tracking(response))
if _replay is not None:
_replay_observe(_replay, response, chunk=False)
_replay_llm_end(_replay)
elif _replay is not None:
response = _ReplayStream(response, _replay)
return response
def _supports_web_search(self) -> bool:
@@ -2021,10 +1837,10 @@ Respond with ONLY a valid JSON tool call in this format:
return None
if self._claude_memory_tool is None:
from ..tools.claude_memory_tool import ClaudeMemoryTool
if isinstance(self.claude_memory, bool):
# Only the default implementation needs this dependency.
from ..tools.claude_memory_tool import ClaudeMemoryTool
# Create default memory tool
self._claude_memory_tool = ClaudeMemoryTool()
else:
# Use provided memory tool instance
@@ -3993,9 +3809,7 @@ Respond with ONLY a valid JSON tool call in this format:
# Mark that fallback completed successfully
fallback_completed = True
streaming_success = False
except BudgetExceededError:
raise
except Exception as fallback_error:
# If non-streaming also fails, create a graceful fallback with partial streaming data
logging.warning(f"Non-streaming fallback also failed: {fallback_error}")
@@ -4521,7 +4335,7 @@ Respond with ONLY a valid JSON tool call in this format:
final_response_text = response_text.strip() if response_text else ""
break
except (LLMResponseError, ToolExecutionError, BudgetExceededError):
except (LLMResponseError, ToolExecutionError):
raise
except Exception as e:
logging.error(f"Error in LLM iteration {iteration_count}: {e}")
@@ -4868,8 +4682,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
interaction_displayed = True
return _prepare_return_value(response_text)
continue
except BudgetExceededError:
raise
except Exception as e:
_get_display_functions()['display_error'](f"Error in LLM response: {str(e)}")
return None
@@ -6865,16 +6677,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _track_token_usage(self, response: Any, model: str) -> Optional[TokenMetrics]:
"""Extract and track token usage from LLM response."""
try:
return self._collect_token_metrics(response, model)
finally:
# Outside the collector's error swallowing so the Agent's budget
# check can raise BudgetExceededError and stop the tool loop.
sink = _usage_sink.get()
if sink is not None and sink[0] is self:
sink[1](response)
def _collect_token_metrics(self, response: Any, model: str) -> Optional[TokenMetrics]:
if not TokenMetrics or not get_token_collector:
return None
@@ -7834,32 +7636,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return params
def _call_responses_api(self, **params) -> Any:
"""``litellm.responses()`` with retry, recorded on the replay trace."""
span = _replay_llm_begin(self, params)
if span is None:
return self._call_responses_api_impl(**params)
try:
response = self._call_responses_api_impl(**params)
except BaseException as exc:
_replay_llm_end(span, exc)
raise
self._replay_observe_responses(span, response)
_replay_llm_end(span)
return response
def _replay_observe_responses(self, span: Dict[str, Any], response: Any) -> None:
"""Usage and text of a Responses API result, for the replay trace."""
try:
span["usage"] = getattr(response, "usage", None) or (
response.get("usage") if isinstance(response, dict) else None
)
text = self._extract_from_responses_output(response)[0]
if isinstance(text, str) and text:
span["content"].append(text)
except Exception:
pass # Tracing must never break a model call.
def _call_responses_api_impl(self, **params) -> Any:
"""
Call ``litellm.responses()`` synchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
@@ -7873,20 +7649,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return self._call_with_retry(litellm.responses, **params)
async def _call_responses_api_async(self, **params) -> Any:
"""``litellm.aresponses()`` with retry, recorded on the replay trace."""
span = _replay_llm_begin(self, params)
if span is None:
return await self._call_responses_api_async_impl(**params)
try:
response = await self._call_responses_api_async_impl(**params)
except BaseException as exc:
_replay_llm_end(span, exc)
raise
self._replay_observe_responses(span, response)
_replay_llm_end(span)
return response
async def _call_responses_api_async_impl(self, **params) -> Any:
"""
Call ``litellm.aresponses()`` asynchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
@@ -8016,20 +7778,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception:
logging.debug("Responses stream cleanup failed", exc_info=True)
def _stream_responses_api(self, params: Dict[str, Any], *args: Any, **kwargs: Any) -> tuple:
"""Streamed ``litellm.responses()``, recorded on the replay trace."""
span = _replay_llm_begin(self, params)
if span is None:
return self._stream_responses_api_impl(params, *args, **kwargs)
try:
result = self._stream_responses_api_impl(params, *args, **kwargs)
except BaseException as exc:
_replay_llm_end(span, exc)
raise
_replay_stream_result(span, result)
return result
def _stream_responses_api_impl(
def _stream_responses_api(
self,
params: Dict[str, Any],
verbose: bool = True,
@@ -8182,20 +7931,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return response_text, tool_calls if tool_calls else None, _final_response
async def _stream_responses_api_async(self, params: Dict[str, Any], *args: Any, **kwargs: Any) -> tuple:
"""Async streamed ``litellm.aresponses()``, recorded on the replay trace."""
span = _replay_llm_begin(self, params)
if span is None:
return await self._stream_responses_api_async_impl(params, *args, **kwargs)
try:
result = await self._stream_responses_api_async_impl(params, *args, **kwargs)
except BaseException as exc:
_replay_llm_end(span, exc)
raise
_replay_stream_result(span, result)
return result
async def _stream_responses_api_async_impl(
async def _stream_responses_api_async(
self,
params: Dict[str, Any],
stream_callback: Optional[Callable] = None,
+38 -66
View File
@@ -127,42 +127,43 @@ class MCPToolRunner(threading.Thread):
# Signal that initialization is complete
self.initialized.set()
# Process requests. Each request runs as its own task so
# one slow call never serializes the others behind it (the
# session multiplexes requests by id), and the queue is read
# with a short blocking get instead of a busy 10ms poll. The
# bounded get keeps the reader thread from outliving the loop.
loop = asyncio.get_running_loop()
tasks = set()
def _next_item():
# Process requests
while True:
try:
return self.queue.get(timeout=0.25)
except queue.Empty:
return queue.Empty
try:
while True:
item = await loop.run_in_executor(None, _next_item)
if item is queue.Empty:
if self._dead:
break # leave stdio_client() so the child is reaped
continue
if item is None: # Shutdown signal
break
if self._dead:
self.queue.put(item) # answered by _fail_pending()
break
task = asyncio.create_task(self._handle_request(session, item))
tasks.add(task)
task.add_done_callback(tasks.discard)
except asyncio.CancelledError:
pass
finally:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
# Check for new requests
try:
item = self.queue.get(block=False)
if item is None: # Shutdown signal
break
response_queue, kind, name, arguments, cancelled = item
if cancelled.is_set():
# Caller already gave up (timed out); never
# execute a call it was told did not happen.
continue
try:
if kind == "resource":
result = await asyncio.wait_for(session.read_resource(name), timeout=self.timeout)
elif kind == "prompt":
result = await asyncio.wait_for(session.get_prompt(name, arguments or None), timeout=self.timeout)
else:
result = await asyncio.wait_for(session.call_tool(name, arguments), timeout=self.timeout)
response_queue.put((True, result))
except asyncio.TimeoutError:
response_queue.put((False, f"MCP {kind} call timed out after {self.timeout} seconds (server side)"))
except Exception as e:
if _is_transport_closed(e):
self._dead = f"MCP server connection lost: {str(e) or type(e).__name__}"
response_queue.put((False, self._dead))
break # leave stdio_client() so the child is reaped
response_queue.put((False, str(e) or type(e).__name__))
except queue.Empty:
pass
# Give other tasks a chance to run
await asyncio.sleep(0.01)
except asyncio.CancelledError:
break
except asyncio.CancelledError:
# stop() cancelled a startup that never completed; leaving the
# contexts above has already terminated the stdio child.
@@ -196,35 +197,6 @@ class MCPToolRunner(threading.Thread):
except Exception:
pass
async def _handle_request(self, session, item):
"""Run one queued request on the session and post its result."""
response_queue, kind, name, arguments, cancelled = item
if cancelled.is_set():
# Caller already gave up (timed out); never execute a call it was
# told did not happen.
return
try:
if kind == "resource":
coro = session.read_resource(name)
elif kind == "prompt":
coro = session.get_prompt(name, arguments or None)
else:
coro = session.call_tool(name, arguments)
result = await asyncio.wait_for(coro, timeout=self.timeout)
response_queue.put((True, result))
except asyncio.TimeoutError:
response_queue.put((False, f"MCP {kind} call timed out after {self.timeout} seconds (server side)"))
except asyncio.CancelledError:
# Report the real cause when a sibling call saw the transport die.
response_queue.put((False, self._dead or f"MCP {kind} call cancelled: runner shut down"))
raise
except Exception as e:
if _is_transport_closed(e):
self._dead = f"MCP server connection lost: {str(e) or type(e).__name__}"
response_queue.put((False, self._dead))
return
response_queue.put((False, str(e) or type(e).__name__))
async def _discover_resources_and_prompts(self, session):
"""Enumerate server resources, resource templates and prompts.
@@ -419,8 +391,8 @@ class MCPToolRunner(threading.Thread):
Queues the shutdown sentinel and joins the thread so the request loop
exits and its stdio subprocess is terminated instead of leaking a
background thread (and its child) across the process lifetime. The
request loop reads the sentinel promptly and cancels any in-flight
calls, which are answered with an error instead of left waiting.
request loop polls the queue between async operations, so once any
in-flight call unwinds the sentinel is consumed promptly.
Args:
timeout: Seconds to wait for the thread to exit. Defaults to the
@@ -13,7 +13,7 @@ Each store handles a specific type of learning data:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING
@@ -31,8 +31,8 @@ class LearnEntry:
id: str
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
use_count: int = 0
last_used: Optional[str] = None
@@ -53,8 +53,8 @@ class LearnEntry:
id=data["id"],
content=data["content"],
metadata=data.get("metadata", {}),
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
created_at=data.get("created_at", datetime.utcnow().isoformat()),
updated_at=data.get("updated_at", datetime.utcnow().isoformat()),
use_count=data.get("use_count", 0),
last_used=data.get("last_used"),
)
@@ -154,13 +154,13 @@ class BaseStore(ABC):
# Update metadata if provided, but don't create duplicate
if metadata:
existing_entry.metadata.update(metadata)
existing_entry.updated_at = datetime.now(timezone.utc).isoformat()
existing_entry.updated_at = datetime.utcnow().isoformat()
self._save()
self._was_updated = True
return existing_entry
# No duplicate found, create new entry
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.now(timezone.utc).timestamp()}"
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
entry = LearnEntry(
id=entry_id,
content=content,
@@ -183,7 +183,7 @@ class BaseStore(ABC):
"""
if not entries:
return
now = datetime.now(timezone.utc).isoformat()
now = datetime.utcnow().isoformat()
for entry in entries:
entry.use_count += 1
entry.last_used = now
@@ -229,7 +229,7 @@ class BaseStore(ABC):
"""List all entries."""
refreshed = self._refresh_for_retrieval()
entries = list(self._entries.values())
entries.sort(key=lambda x: self._timestamp_sort_key(x.updated_at), reverse=True)
entries.sort(key=lambda x: x.updated_at, reverse=True)
entries = entries[:limit]
if refreshed:
self._touch(entries)
@@ -244,7 +244,7 @@ class BaseStore(ABC):
entry.content = content
if metadata:
entry.metadata.update(metadata)
entry.updated_at = datetime.now(timezone.utc).isoformat()
entry.updated_at = datetime.utcnow().isoformat()
self._save()
self._was_updated = True
return entry
@@ -308,29 +308,9 @@ class BaseStore(ABC):
ref_dt = datetime.fromisoformat(reference)
except (ValueError, TypeError):
return False
if ref_dt.tzinfo is None:
ref_dt = ref_dt.replace(tzinfo=timezone.utc)
age_days = (now - ref_dt).total_seconds() / 86400.0
return age_days > self.retention_days
@staticmethod
def _timestamp_sort_key(value: str) -> tuple:
"""Comparable key that orders ISO timestamps chronologically in UTC.
Naive values are treated as UTC (matching how older stores were written).
Missing or unparseable values sort as the oldest instant, so prune()
evicts them first (an entry never used sorts before used ones, as it did
under the previous raw-string sort) and list_all() lists them last. A
datetime is never compared against a string, so mixed data never raises.
"""
try:
parsed = datetime.fromisoformat(value)
except (ValueError, TypeError):
return (-1, value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return (0, parsed.astimezone(timezone.utc))
def prune(self, save: bool = True, protect_id: Optional[str] = None) -> int:
"""Archive stale and least-used/oldest excess entries.
@@ -351,7 +331,7 @@ class BaseStore(ABC):
if self.max_entries <= 0 and self.retention_days <= 0:
return 0
now = datetime.now(timezone.utc)
now = datetime.utcnow()
evicted: List[LearnEntry] = []
# 1. Staleness: archive entries beyond the retention window
@@ -369,11 +349,7 @@ class BaseStore(ABC):
]
remaining = sorted(
candidates,
key=lambda e: (
e.use_count,
self._timestamp_sort_key(e.last_used or ""),
self._timestamp_sort_key(e.created_at),
),
key=lambda e: (e.use_count, e.last_used or "", e.created_at),
)
overflow = len(self._entries) - self.max_entries
for entry in remaining[:overflow]:
@@ -5,7 +5,6 @@ Detects and prevents agents from getting stuck in repetitive loops.
"""
from praisonaiagents._logging import get_logger
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
@@ -94,13 +93,8 @@ class DoomLoopDetector:
self.max_tool_calls = max_tool_calls
self._records: List[ToolCallRecord] = []
# Lifetime per-tool totals, reported by get_stats() only. Loop
# decisions use the windowed _records so a slow, healthy tool is
# never permanently denied.
self._call_counts: Dict[str, int] = defaultdict(int)
self._session_id: Optional[str] = None
# check_doom_loop is reached from concurrent tool execution.
self._lock = threading.RLock()
def set_session(self, session_id: str):
"""Set the current session ID."""
@@ -131,18 +125,19 @@ class DoomLoopDetector:
Returns:
The recorded ToolCallRecord
"""
self._cleanup_old_records()
args_hash = self._hash_arguments(arguments or {})
with self._lock:
self._cleanup_old_records()
record = ToolCallRecord(
tool_name=tool_name,
arguments_hash=args_hash,
session_id=self._session_id,
)
self._records.append(record)
self._call_counts[tool_name] += 1
record = ToolCallRecord(
tool_name=tool_name,
arguments_hash=args_hash,
session_id=self._session_id,
)
self._records.append(record)
self._call_counts[tool_name] += 1
return record
def check(
@@ -160,15 +155,13 @@ class DoomLoopDetector:
Returns:
DoomLoopResult indicating if a loop is detected
"""
self._cleanup_old_records()
args_hash = self._hash_arguments(arguments or {})
with self._lock:
self._cleanup_old_records()
records = list(self._records)
# Count identical calls in window
identical_count = sum(
1 for r in records
1 for r in self._records
if r.tool_name == tool_name and r.arguments_hash == args_hash
)
@@ -183,7 +176,7 @@ class DoomLoopDetector:
)
# Check for excessive tool calls
total_calls = len(records)
total_calls = len(self._records)
if total_calls >= self.max_tool_calls:
return DoomLoopResult(
is_loop=True,
@@ -192,8 +185,8 @@ class DoomLoopDetector:
recommendation="Reduce tool usage or break task into smaller steps",
)
# Check for tool-specific excessive calls (within the window)
tool_count = sum(1 for r in records if r.tool_name == tool_name)
# Check for tool-specific excessive calls
tool_count = self._call_counts.get(tool_name, 0)
if tool_count >= self.max_tool_calls // 2:
return DoomLoopResult(
is_loop=True,
@@ -226,32 +219,29 @@ class DoomLoopDetector:
Returns:
DoomLoopResult indicating if a loop is detected
"""
with self._lock:
result = self.check(tool_name, arguments)
if not result.is_loop:
self.record(tool_name, arguments)
result = self.check(tool_name, arguments)
if not result.is_loop:
self.record(tool_name, arguments)
return result
def get_stats(self) -> Dict[str, Any]:
"""Get statistics about tool calls."""
with self._lock:
self._cleanup_old_records()
return {
"total_calls": len(self._records),
"unique_tools": len(set(r.tool_name for r in self._records)),
"call_counts": dict(self._call_counts),
"window_seconds": self.window_seconds,
"loop_threshold": self.loop_threshold,
}
self._cleanup_old_records()
return {
"total_calls": len(self._records),
"unique_tools": len(set(r.tool_name for r in self._records)),
"call_counts": dict(self._call_counts),
"window_seconds": self.window_seconds,
"loop_threshold": self.loop_threshold,
}
def reset(self):
"""Reset all records."""
with self._lock:
self._records.clear()
self._call_counts.clear()
self._records.clear()
self._call_counts.clear()
def reset_tool(self, tool_name: str):
"""Reset records for a specific tool."""
with self._lock:
self._records = [r for r in self._records if r.tool_name != tool_name]
self._call_counts.pop(tool_name, None)
self._records = [r for r in self._records if r.tool_name != tool_name]
self._call_counts.pop(tool_name, None)
@@ -80,31 +80,6 @@ class Process:
self._state_lock = asyncio.Lock()
return self._state_lock
def _trace_delegate(self, task_id, tasks_summary):
"""Record the manager's choice as a ``delegate`` hand-off (tracing on only)."""
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled:
return
from ..workflows.diagram import task_node_id
task = self.tasks[task_id]
agent = getattr(getattr(task, "agent", None), "name", None)
emitter.agent_handoff(
"Manager",
agent,
reason=f"Manager delegated task {getattr(task, 'name', None) or task_id}",
context_passed={
"task_id": task_id,
"task_status": [
{"task_id": t.get("task_id"), "name": t.get("name"),
"status": t.get("status"), "agent": t.get("agent")}
for t in tasks_summary
],
},
kind="delegate",
node_id=task_node_id(self.tasks, task),
)
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)
@@ -1281,7 +1256,6 @@ Provide a JSON with the structure:
if self.tasks[selected_task_id].status != "completed":
logging.info(f"Starting execution of task {selected_task_id}")
self._trace_delegate(selected_task_id, tasks_summary)
yield selected_task_id
logging.info(f"Finished execution of task {selected_task_id}, status: {self.tasks[selected_task_id].status}")
# Count a task toward completion only the first time it actually
@@ -1942,7 +1916,6 @@ Provide a JSON with the structure:
if self.tasks[selected_task_id].status != "completed":
logging.info(f"Starting execution of task {selected_task_id}")
self._trace_delegate(selected_task_id, tasks_summary)
yield selected_task_id
logging.info(f"Finished execution of task {selected_task_id}, status: {self.tasks[selected_task_id].status}")
# Count a task toward completion only the first time it actually
@@ -154,7 +154,6 @@ class RAG:
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
top_k: Optional[int] = None,
**kwargs,
) -> List[Dict[str, Any]]:
"""
@@ -194,27 +193,15 @@ class RAG:
filtered.append(r)
results = filtered
# Limit to top_k (per-call value, never written back to shared config)
results = results[:self.config.top_k if top_k is None else top_k]
# Limit to top_k
results = results[:self.config.top_k]
return results
def _reranked(self, rerank: bool) -> bool:
"""Whether a reranker actually ran: the local one, or a backend
reranker configured on the knowledge (e.g. mem0 with rerank_model)."""
if not rerank:
return False
if self.reranker:
return True
cfg = getattr(self.knowledge, "config", None)
backend = cfg.get("reranker") if isinstance(cfg, dict) else None
return bool(isinstance(backend, dict) and backend.get("default_rerank") and backend.get("provider"))
def _rerank(
self,
query: str,
results: List[Dict[str, Any]],
rerank: Optional[bool] = None,
) -> List[Dict[str, Any]]:
"""
Rerank results if reranker is configured.
@@ -226,9 +213,7 @@ class RAG:
Returns:
Reranked results
"""
if rerank is None:
rerank = self.config.rerank
if not rerank or not self.reranker:
if not self.config.rerank or not self.reranker:
return results
try:
@@ -369,47 +354,54 @@ class RAG:
"""
start_time = time.time()
# Resolve per-call overrides without mutating the shared config:
# aretrieve() runs this in an executor, so concurrent calls on one RAG
# would otherwise see (and permanently "restore") each other's values.
eff_top_k = self.config.top_k if top_k is None else top_k
eff_rerank = self.config.rerank if rerank is None else rerank
# Apply overrides
original_top_k = self.config.top_k
original_rerank = self.config.rerank
# 1. Retrieve
results = self._retrieve(
query=query,
user_id=user_id,
agent_id=agent_id,
run_id=run_id,
filters=filters,
top_k=eff_top_k,
**kwargs,
)
if top_k is not None:
self.config.top_k = top_k
if rerank is not None:
self.config.rerank = rerank
# 2. Rerank (optional)
results = self._rerank(query, results, rerank=eff_rerank)
# 3. Build context
context, used_results = self._build_context(results)
# 4. Format citations
citations = self._format_citations(used_results)
elapsed = time.time() - start_time
return ContextPack(
context=context,
citations=citations,
query=query,
metadata={
"elapsed_seconds": elapsed,
"num_results": len(results),
"num_citations": len(citations),
"retrieval_strategy": self.config.retrieval_strategy.value,
"reranked": self._reranked(eff_rerank),
"top_k": eff_top_k,
},
)
try:
# 1. Retrieve
results = self._retrieve(
query=query,
user_id=user_id,
agent_id=agent_id,
run_id=run_id,
filters=filters,
**kwargs,
)
# 2. Rerank (optional)
results = self._rerank(query, results)
# 3. Build context
context, used_results = self._build_context(results)
# 4. Format citations
citations = self._format_citations(used_results)
elapsed = time.time() - start_time
return ContextPack(
context=context,
citations=citations,
query=query,
metadata={
"elapsed_seconds": elapsed,
"num_results": len(results),
"num_citations": len(citations),
"retrieval_strategy": self.config.retrieval_strategy.value,
"reranked": self.config.rerank,
"top_k": self.config.top_k,
},
)
finally:
# Restore original config
self.config.top_k = original_top_k
self.config.rerank = original_rerank
async def aretrieve(
self,
@@ -506,7 +498,7 @@ class RAG:
"num_results": len(results),
"num_citations": len(citations),
"retrieval_strategy": self.config.retrieval_strategy.value,
"reranked": self._reranked(self.config.rerank),
"reranked": self.config.rerank,
},
)
@@ -131,44 +131,26 @@ class SmartRetriever:
if not self._knowledge:
return result
has_filters = bool(include_glob or exclude_glob or path_filter or metadata_filters)
if path_filter:
import re
try:
re.compile(path_filter)
except re.error as e:
# Fail closed: silently dropping the filter would return every
# chunk while still reporting the result as filtered.
raise ValueError(f"Invalid path_filter regex {path_filter!r}: {e}") from e
# Filters are applied after the store truncates, so over-fetch when any
# is set (same rule as Memory's metadata_filter search) -- otherwise a
# filter whose matches sit just below the top_k cut returns nothing.
fetch_k = top_k * 3 if strategy in ("reranked", "hybrid") else top_k
if has_filters:
fetch_k = max(fetch_k, top_k * 10)
# Get initial results from knowledge store
search_results, error = self._search(
search_results = self._search(
query=query,
top_k=fetch_k,
top_k=top_k * 3 if strategy in ("reranked", "hybrid") else top_k,
user_id=user_id,
agent_id=agent_id,
)
# Surface a store failure on the result. Without this the caller sees an
# empty chunk list and cannot tell whether the knowledge base had no
# match or was never reached. The error is returned rather than stored
# on self so concurrent calls on one retriever cannot see each other's.
if error:
# match or was never reached.
if getattr(self, "_last_error", None):
result.retrieval_failed = True
result.error = error
result.metadata["retrieval_error"] = error
result.error = self._last_error
result.metadata["retrieval_error"] = self._last_error
chunks = self._normalize_results(search_results)
result.total_found = len(chunks)
# Apply filters
if has_filters:
if include_glob or exclude_glob or path_filter or metadata_filters:
chunks = self._apply_filters(
chunks,
include_glob=include_glob,
@@ -194,22 +176,23 @@ class SmartRetriever:
top_k: int,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
) -> tuple:
"""Perform search on knowledge store. Returns ``(results, error)``."""
) -> Any:
"""Perform search on knowledge store."""
self._last_error = None
try:
return self._knowledge.search(
query,
user_id=user_id,
agent_id=agent_id,
limit=top_k,
), None
)
except Exception as e:
# Record the failure rather than returning a bare [] that is
# indistinguishable from "nothing matched". Knowledge.search itself
# re-raises, so reaching here means the store really is broken --
# misconfigured, unreachable, or refusing auth -- and the agent is
# about to answer with no context at all.
error = f"{type(e).__name__}: {e}"
self._last_error = f"{type(e).__name__}: {e}"
# Do not log the raw query: it may carry personal data, credentials
# or proprietary text, and this line lands at ERROR in application
# logs. The exception text is enough to diagnose the store; the
@@ -217,9 +200,9 @@ class SmartRetriever:
logger.error(
"Knowledge retrieval failed (query length %d): %s. "
"Returning no chunks; the answer will not use the knowledge base.",
len(query or ""), error,
len(query or ""), self._last_error,
)
return [], error
return []
def _normalize_results(self, results: Any) -> List[Dict[str, Any]]:
"""Normalize search results to list of dicts."""
@@ -292,8 +275,12 @@ class SmartRetriever:
continue
# Check path filter regex
if path_filter and not re.search(path_filter, filepath):
continue
if path_filter:
try:
if not re.search(path_filter, filepath):
continue
except re.error:
pass
# Check metadata filters
if metadata_filters:
@@ -332,10 +319,7 @@ class SimpleReranker:
scored = []
for result in results:
# Knowledge adapters may return SearchResultItem objects
if not isinstance(result, dict) and hasattr(result, 'to_dict'):
result = result.to_dict()
text = (result.get('text') or result.get('memory') or '').lower()
text = result.get('text', '').lower()
text_words = set(text.split())
# Calculate overlap score
@@ -29,10 +29,6 @@ DEFAULT_MAX_LINES = 2000
# cannot dominate the window.
DEFAULT_MAX_LINE_CHARS = 2000
# Default byte cap for ``download_file`` so an endless or oversized response
# cannot fill the disk. Override with PRAISONAI_MAX_DOWNLOAD_BYTES.
_DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024
def _render_with_line_numbers(lines, start_index, max_line_chars):
"""Render ``lines`` with a right-aligned 1-based line-number gutter.
@@ -489,63 +485,58 @@ class FileTools:
# Validate destination path
safe_path = self._validate_path(destination)
# Validate URL to prevent SSRF (shared guard used by all HTTP tools)
# Validate URL to prevent SSRF
from urllib.parse import urlparse
from .url_safety import is_safe_http_url
import ipaddress
import socket
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return {
"success": False, "path": "", "size": 0,
"error": f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed."
}
# allow_local=False: download_file never had a local-access opt-in,
# so ALLOW_LOCAL_CRAWL must not bypass this check.
if not is_safe_http_url(url, allow_local=False):
hostname = parsed.hostname or ""
# Resolve hostname and check for private/reserved IPs
try:
resolved = socket.getaddrinfo(hostname, parsed.port or 443)
for family, _, _, _, sockaddr in resolved:
addr = ipaddress.ip_address(sockaddr[0])
if addr.is_private or addr.is_loopback or addr.is_reserved or addr.is_link_local:
return {
"success": False, "path": "", "size": 0,
"error": f"Access to private/internal addresses is blocked: {hostname}"
}
except (socket.gaierror, ValueError):
pass # Let httpx handle DNS failures naturally
# Block cloud metadata endpoints
_blocked_hosts = {"169.254.169.254", "metadata.google.internal", "metadata.google.com"}
if hostname in _blocked_hosts:
return {
"success": False, "path": "", "size": 0,
"error": f"Access to private/internal addresses is blocked: {parsed.hostname or ''}"
"error": f"Access to metadata service is blocked: {hostname}"
}
max_bytes = int(os.environ.get("PRAISONAI_MAX_DOWNLOAD_BYTES", _DEFAULT_MAX_DOWNLOAD_BYTES))
# Create directory if needed
dest_dir = os.path.dirname(safe_path) or "."
os.makedirs(dest_dir, exist_ok=True)
# Download into a temp file next to the destination and only
# replace the destination on success, so a failed or oversized
# download never clobbers an existing file.
import tempfile
fd, tmp_path = tempfile.mkstemp(dir=dest_dir, suffix=".part")
try:
with os.fdopen(fd, "wb") as f, \
httpx.stream("GET", url, timeout=timeout, follow_redirects=False) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
downloaded = 0
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
# Download file
with httpx.stream("GET", url, timeout=timeout, follow_redirects=False) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
downloaded = 0
with open(safe_path, "wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
downloaded += len(chunk)
if downloaded > max_bytes:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback and total_size:
progress_callback(downloaded, total_size)
if downloaded > max_bytes:
return {
"success": False, "path": "", "size": 0,
"error": f"Download exceeds {max_bytes} bytes"
}
os.replace(tmp_path, safe_path)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return {
"success": True,
"path": safe_path,
@@ -3,7 +3,7 @@ import re
import subprocess
import logging
import secrets
from typing import List, Optional
from typing import Optional
from .decorator import tool
logger = logging.getLogger(__name__)
@@ -11,22 +11,22 @@ logger = logging.getLogger(__name__)
AGENT_BRANCH_PREFIX = "praisonai/"
def _git(*args: str, cwd: Optional[str] = None) -> subprocess.CompletedProcess:
"""Run a git command (in ``cwd`` when given) capturing output as text."""
return subprocess.run(["git", *args], check=True, capture_output=True, text=True, cwd=cwd)
def _git(*args: str) -> subprocess.CompletedProcess:
"""Run a git command capturing output as text."""
return subprocess.run(["git", *args], check=True, capture_output=True, text=True)
def _default_branch(cwd: Optional[str] = None) -> str:
def _default_branch() -> str:
"""Detect the remote default branch, falling back to main/master."""
try:
ref = _git("symbolic-ref", "refs/remotes/origin/HEAD", cwd=cwd).stdout.strip()
ref = _git("symbolic-ref", "refs/remotes/origin/HEAD").stdout.strip()
# e.g. refs/remotes/origin/main -> main
return ref.rsplit("/", 1)[-1]
except subprocess.CalledProcessError:
pass
for candidate in ("main", "master"):
try:
_git("show-ref", "--verify", "--quiet", f"refs/remotes/origin/{candidate}", cwd=cwd)
_git("show-ref", "--verify", "--quiet", f"refs/remotes/origin/{candidate}")
return candidate
except subprocess.CalledProcessError:
continue
@@ -45,7 +45,7 @@ def _auto_branch_name(commit_message: str) -> str:
return f"{AGENT_BRANCH_PREFIX}{_slugify(commit_message)}-{secrets.token_hex(3)}"
def _divergence_refusal(branch: str, cwd: Optional[str] = None) -> Optional[str]:
def _divergence_refusal(branch: str) -> Optional[str]:
"""Return a refusal reason if pushing ``branch`` would require a force.
A force push is required when the remote branch exists but is not an
@@ -54,13 +54,13 @@ def _divergence_refusal(branch: str, cwd: Optional[str] = None) -> Optional[str]
agent-prefixed ones.
"""
try:
_git("show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", cwd=cwd)
_git("show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}")
remote_exists = True
except subprocess.CalledProcessError:
remote_exists = False
if remote_exists:
try:
_git("merge-base", "--is-ancestor", f"origin/{branch}", "HEAD", cwd=cwd)
_git("merge-base", "--is-ancestor", f"origin/{branch}", "HEAD")
except subprocess.CalledProcessError:
return (
f"refusing to push to '{branch}': the remote branch has "
@@ -69,7 +69,7 @@ def _divergence_refusal(branch: str, cwd: Optional[str] = None) -> Optional[str]
return None
def _branch_push_safety(branch: str, cwd: Optional[str] = None) -> Optional[str]:
def _branch_push_safety(branch: str) -> Optional[str]:
"""Return a refusal reason if pushing ``branch`` is unsafe, else None.
Rules (see github_commit_and_push docstring):
@@ -82,14 +82,14 @@ def _branch_push_safety(branch: str, cwd: Optional[str] = None) -> Optional[str]
(i.e. would require a force push) are refused.
"""
# Divergence (force-push) is refused for every branch, agent-prefixed or not.
diverged = _divergence_refusal(branch, cwd=cwd)
diverged = _divergence_refusal(branch)
if diverged:
return diverged
if branch.startswith(AGENT_BRANCH_PREFIX):
return None
default = _default_branch(cwd=cwd)
default = _default_branch()
if branch == default:
return (
f"refusing to push to '{branch}': it is the repository "
@@ -99,16 +99,16 @@ def _branch_push_safety(branch: str, cwd: Optional[str] = None) -> Optional[str]
# Foreign-author check: commits on this branch since the merge-base with
# the default branch must all be authored by the configured committer.
try:
committer = _git("config", "user.email", cwd=cwd).stdout.strip()
committer = _git("config", "user.email").stdout.strip()
except subprocess.CalledProcessError:
committer = ""
if committer:
try:
merge_base = _git(
"merge-base", f"origin/{default}", "HEAD", cwd=cwd
"merge-base", f"origin/{default}", "HEAD"
).stdout.strip()
authors = _git(
"log", "--format=%ae", f"{merge_base}..HEAD", cwd=cwd
"log", "--format=%ae", f"{merge_base}..HEAD"
).stdout.split()
foreign = {a for a in authors if a and a != committer}
if foreign:
@@ -123,27 +123,25 @@ def _branch_push_safety(branch: str, cwd: Optional[str] = None) -> Optional[str]
return None
@tool
def github_create_branch(branch_name: str, repo_path: Optional[str] = None) -> str:
def github_create_branch(branch_name: str) -> str:
"""Create and checkout a new git branch.
Args:
branch_name: The name of the branch to create and checkout.
repo_path: Repository directory to run git in. Defaults to the
current working directory.
"""
try:
# Check if we are in a git repository
subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], check=True, capture_output=True, cwd=repo_path)
subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], check=True, capture_output=True)
# Validate branch name to prevent misinterpretation as git options or invalid refs
try:
subprocess.run(
["git", "check-ref-format", f"refs/heads/{branch_name}"],
check=True, capture_output=True, text=True, cwd=repo_path
check=True, capture_output=True, text=True
)
except subprocess.CalledProcessError as e:
return f"Error: invalid branch name '{branch_name}': {e.stderr.strip() if e.stderr else 'branch name is not a valid git ref'}"
subprocess.run(["git", "checkout", "-B", branch_name], check=True, capture_output=True, text=True, cwd=repo_path)
subprocess.run(["git", "checkout", "-B", branch_name], check=True, capture_output=True, text=True)
logger.debug(f"Branch '{branch_name}' checked out successfully.")
logger.debug(f"Successfully checked out branch '{branch_name}'")
return f"Successfully created and checked out branch '{branch_name}'"
@@ -156,15 +154,8 @@ def github_commit_and_push(
commit_message: str,
branch: Optional[str] = None,
allow_unsafe_branch: bool = False,
repo_path: Optional[str] = None,
paths: Optional[List[str]] = None,
) -> str:
"""Stage tracked changes, commit them, and push to the remote repository safely.
Only changes to already-tracked files under the working directory are
staged (``git add -u -- .``); new files are committed only when listed in
``paths``. Staging happens after the safety checks below, so a refusal
leaves the index untouched.
"""Stage all changes, commit them, and push to the remote repository safely.
Pushes are safe by construction. Before pushing, these rules are enforced:
@@ -185,27 +176,25 @@ def github_commit_and_push(
branch: Target branch to push to. Defaults to the current branch.
allow_unsafe_branch: Opt-in override for the default/foreign-author
refusals. Divergence (force-push) is never allowed.
repo_path: Repository directory to run git in. Defaults to the
current working directory.
paths: Extra (e.g. untracked) files to stage alongside tracked changes.
"""
try:
allow_unsafe = allow_unsafe_branch or os.environ.get(
"PRAISONAI_GIT_ALLOW_UNSAFE_BRANCH", ""
).lower() in ("1", "true", "yes")
# Check if there are changes to commit (tracked changes or explicit paths)
status = _git(
"status", "--porcelain", "--untracked-files=no", "--", ".", cwd=repo_path
)
if not status.stdout.strip() and not paths:
# Add all changes
subprocess.run(["git", "add", "."], check=True, capture_output=True)
# Check if there are changes to commit
status = subprocess.run(["git", "status", "--porcelain"], check=True, capture_output=True, text=True)
if not status.stdout.strip():
return "No changes to commit."
# Resolve target branch (defaults to current)
current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=repo_path).stdout.strip()
current_branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
target_branch = branch or current_branch
default = _default_branch(cwd=repo_path)
default = _default_branch()
auto_created = False
# Rule 2: default branch is never pushed directly (unless overridden).
@@ -218,7 +207,7 @@ def github_commit_and_push(
# retry can then succeed). Adding the new commit does not change the
# divergence/foreign-author verdicts, which are about existing history.
if not allow_unsafe:
reason = _branch_push_safety(target_branch, cwd=repo_path)
reason = _branch_push_safety(target_branch)
if reason:
return f"Error: {reason}"
else:
@@ -228,14 +217,14 @@ def github_commit_and_push(
"checks for '%s'", target_branch
)
# Even when overriding, divergence (force) is never permitted.
diverged = _divergence_refusal(target_branch, cwd=repo_path)
diverged = _divergence_refusal(target_branch)
if diverged:
return f"Error: {diverged}"
# Move to the resolved branch only after passing safety checks.
if auto_created:
# Fresh agent branch created from current HEAD.
_git("checkout", "-B", target_branch, cwd=repo_path)
_git("checkout", "-B", target_branch)
elif target_branch != current_branch:
# Switch to the requested branch without discarding its commits:
# ``-B`` would reset an existing branch to HEAD, so use a plain
@@ -244,24 +233,17 @@ def github_commit_and_push(
try:
_git(
"show-ref", "--verify", "--quiet",
f"refs/heads/{target_branch}", cwd=repo_path,
f"refs/heads/{target_branch}",
)
_git("checkout", target_branch, cwd=repo_path)
_git("checkout", target_branch)
except subprocess.CalledProcessError:
_git("checkout", "-b", target_branch, cwd=repo_path)
# Stage tracked changes under cwd plus explicitly listed paths (never
# ``git add .``). The ``.`` pathspec keeps ``-u`` from reaching sibling
# directories when run from a repository subdirectory.
_git("add", "-u", "--", ".", cwd=repo_path)
if paths:
_git("add", "--", *paths, cwd=repo_path)
_git("checkout", "-b", target_branch)
# Commit changes on the resolved branch
_git("commit", "-m", commit_message, cwd=repo_path)
subprocess.run(["git", "commit", "-m", commit_message], check=True, capture_output=True, text=True)
# Push to remote (never with --force)
_git("push", "-u", "origin", target_branch, cwd=repo_path)
subprocess.run(["git", "push", "-u", "origin", target_branch], check=True, capture_output=True, text=True)
if auto_created:
return (
f"Successfully committed and pushed changes to new agent branch "
@@ -273,13 +255,7 @@ def github_commit_and_push(
return f"Error committing and pushing: {e.stderr}"
@tool
def github_create_pull_request(
title: str,
body: str,
head_branch: str,
base_branch: str = "main",
repo_path: Optional[str] = None,
) -> str:
def github_create_pull_request(title: str, body: str, head_branch: str, base_branch: str = "main") -> str:
"""Create a Pull Request on GitHub using the gh CLI.
Args:
@@ -291,8 +267,6 @@ def github_create_pull_request(
This is typically the feature branch you've been working on.
base_branch: The target branch to merge your changes into. Usually the main
development branch like "main", "master", or "develop". Defaults to "main".
repo_path: Repository directory to run gh in. Defaults to the current
working directory.
Returns:
str: Success message with PR URL if created successfully, or error message if failed.
@@ -308,7 +282,7 @@ def github_create_pull_request(
"""
try:
# Verify gh CLI is installed and authenticated
subprocess.run(["gh", "auth", "status"], check=True, capture_output=True, cwd=repo_path)
subprocess.run(["gh", "auth", "status"], check=True, capture_output=True)
# Create PR
cmd = [
@@ -318,7 +292,7 @@ def github_create_pull_request(
"--head", head_branch,
"--base", base_branch
]
result = subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=repo_path)
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
return f"Successfully created Pull Request:\n{result.stdout.strip()}"
except subprocess.CalledProcessError as e:
logger.error(f"Failed to create PR: {e.stderr}")
@@ -89,10 +89,7 @@ def is_safe_http_url(
continue
return False
if (
# is_global also catches shared/CGNAT space (100.64.0.0/10,
# e.g. Alibaba Cloud metadata), which is_private misses.
not ip.is_global
or ip.is_private
ip.is_private
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
@@ -9,12 +9,10 @@ Zero Performance Impact:
- Disabled emitter has near-zero overhead
- Uses __slots__ for memory efficiency
Schema Version: 1.1 (adds optional run_id / parent_seq / node_id; 1.0 files still load)
Schema Version: 1.0
"""
import contextvars
import itertools
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
@@ -23,7 +21,7 @@ from typing import Any, Dict, Generator, List, Optional, Protocol, runtime_check
import json
CONTEXT_SCHEMA_VERSION = "1.1"
CONTEXT_SCHEMA_VERSION = "1.0"
# =============================================================================
@@ -39,28 +37,6 @@ _context_emitter: contextvars.ContextVar["ContextTraceEmitter"] = contextvars.Co
# Singleton disabled emitter for default case (zero allocation per call)
_default_disabled_emitter: Optional["ContextTraceEmitter"] = None
# Per-context run state. These live in contextvars, not on the emitter, so
# concurrent Parallel/Loop branches (each running in its own copied context, see
# copy_context_to_callable) and gathered asyncio tasks cannot clobber each
# other. Values are immutable tuples and are only ever replaced via .set(), so
# a copied context never shares a mutable object with its parent.
#
# Entries are keyed by the owning emitter's ``_uid`` so state set through one
# emitter never leaks into events of another emitter in the same context.
_branch_var: contextvars.ContextVar = contextvars.ContextVar(
'praisonai_trace_branch', default=None # (emitter_uid, branch_id)
)
_span_var: contextvars.ContextVar = contextvars.ContextVar(
'praisonai_trace_spans', default=() # ((emitter_uid, seq, kind, key), ...)
)
_run_id_var: contextvars.ContextVar = contextvars.ContextVar(
'praisonai_trace_run_id', default=None
)
_llm_open_var: contextvars.ContextVar = contextvars.ContextVar(
'praisonai_trace_llm_open', default=None # (emitter_uid, parent_seq, previous)
)
_emitter_uids = itertools.count(1)
def get_context_emitter() -> "ContextTraceEmitter":
"""
@@ -85,16 +61,6 @@ def get_context_emitter() -> "ContextTraceEmitter":
return _default_disabled_emitter
def _agent_key(name: Optional[str]) -> str:
"""The id an agent has in traces and graphs: its name, or "Agent" when unnamed.
``Agent(instructions=...)`` has ``name=None``; without this its events had no
node id and its tools were recorded as ``tool:None/<tool>``, so nothing
matched the ``agent:Agent`` node in its graph.
"""
return name or "Agent"
def set_context_emitter(emitter: "ContextTraceEmitter") -> contextvars.Token:
"""
Set the context trace emitter for the current async context.
@@ -148,130 +114,12 @@ def trace_context(emitter: "ContextTraceEmitter") -> Generator["ContextTraceEmit
reset_context_emitter(token)
@contextmanager
def _run_scope(run_id: Optional[str] = None) -> Generator[str, None, None]:
"""Stamp ``run_id`` on every event emitted inside the block.
Nested teams and flows inherit the OUTER run id: when a run id is already
in scope it is kept and ``run_id`` is ignored. A fresh uuid is used when no
id is given. Yields the effective run id.
The recording is the run boundary: inside an outer scope (for example
``praisonai.replay.record()``) every team or flow start shares one run id;
outside one, a team run is stamped with its own ``AgentTeam.run_id``.
"""
outer = _run_id_var.get()
if outer is not None:
yield outer
return
if not run_id:
import uuid
run_id = uuid.uuid4().hex
token = _run_id_var.set(run_id)
try:
yield run_id
finally:
try:
_run_id_var.reset(token)
except ValueError:
# Exited from a different context than it was entered in.
_run_id_var.set(None)
@contextmanager
def _workflow_scope(
kind: str,
name: Optional[str],
process: str,
graph_fn=None,
run_id: Optional[str] = None,
) -> Generator[Optional[Dict[str, Any]], None, None]:
"""Emit ``workflow_start``/``workflow_end`` around a team or flow run.
Yields ``None`` (and does nothing else, not even building the graph) when
the current emitter is disabled. Otherwise yields a mutable dict whose
``"status"`` the caller may set to ``"failed"``; an exception escaping the
block always records ``"failed"``.
"""
emitter = get_context_emitter()
if not emitter._enabled:
yield None
return
with _run_scope(run_id):
graph = None
if graph_fn is not None:
try:
graph = graph_fn()
except Exception:
graph = None # A diagram must never break a run.
emitter.workflow_start(kind, name, process, graph=graph)
state: Dict[str, Any] = {"status": "completed"}
started = time.perf_counter()
try:
yield state
except BaseException:
state["status"] = "failed"
raise
finally:
emitter.workflow_end(
status=state.get("status") or "completed",
duration_ms=(time.perf_counter() - started) * 1000,
)
def _approval_begin(request: Any, kind: str = "tool_call", node_id: Optional[str] = None):
"""Emit ``approval_requested`` for an ``ApprovalRequest``; token or None."""
emitter = get_context_emitter()
if not emitter._enabled:
return None
agent_name = getattr(request, "agent_name", None)
tool = getattr(request, "tool_name", None) or ""
if node_id is None and kind == "tool_call":
node_id = f"tool:{agent_name}/{tool}" if agent_name else None
emitter.approval_requested(
getattr(request, "approval_id", None) or "",
tool,
args=getattr(request, "arguments", None),
risk=getattr(request, "risk_level", None),
kind=kind,
agent_name=agent_name,
node_id=node_id,
)
return emitter, request, node_id
def _approval_end(token, decision: Any = None, error: Optional[BaseException] = None) -> None:
"""Emit ``approval_decided`` for a token from :func:`_approval_begin`."""
if token is None:
return
emitter, request, node_id = token
if decision is None:
approved, reason, approver, modified = False, f"approval failed: {error}", None, False
else:
approved = bool(getattr(decision, "approved", False))
reason = getattr(decision, "reason", None)
approver = getattr(decision, "approver", None)
modified = bool(getattr(decision, "modified_args", None))
emitter.approval_decided(
getattr(request, "approval_id", None) or "",
approved,
reason=reason,
approver=approver,
modified=modified,
agent_name=getattr(request, "agent_name", None),
node_id=node_id,
)
def copy_context_to_callable(func):
"""
Wrap a callable to copy the current context to the new thread/executor.
This is needed because contextvars are NOT automatically propagated to
thread pool executors. Use this when calling run_in_executor().
The whole context is copied, so the replay state (emitter, run id, branch
id and the open-span stack that yields ``parent_seq``) reaches the thread.
Usage:
# Instead of:
@@ -311,53 +159,6 @@ class ContextEventType(str, Enum):
# Knowledge events for knowledge utilization tracking
KNOWLEDGE_SEARCH = "knowledge_search"
KNOWLEDGE_ADD = "knowledge_add"
# Run structure (schema 1.1): teams/flows, tasks, flow steps, approvals
WORKFLOW_START = "workflow_start"
WORKFLOW_END = "workflow_end"
TASK_START = "task_start"
TASK_END = "task_end"
STEP_START = "step_start"
STEP_END = "step_end"
APPROVAL_REQUESTED = "approval_requested"
APPROVAL_DECIDED = "approval_decided"
class _UnknownEventType(str):
"""An ``event_type`` string this version does not know.
``from_dict`` keeps the raw value instead of relabelling it, so a trace
written by a newer version stays readable. ``.value`` mirrors the Enum API
so existing ``event.event_type.value`` callers keep working.
"""
__slots__ = ()
@property
def value(self) -> str:
return str(self)
# Span bookkeeping: which event types open/close a span, and of which kind.
_SPAN_START = {
"session_start": "session",
"agent_start": "agent",
"tool_call_start": "tool_call",
"workflow_start": "workflow",
"task_start": "task",
"step_start": "step",
"approval_requested": "approval",
}
_SPAN_END = {
"session_end": "session",
"agent_end": "agent",
"tool_call_end": "tool_call",
"workflow_end": "workflow",
"task_end": "task",
"step_end": "step",
"approval_decided": "approval",
}
_PREVIEW_LIMIT = 500
_UNSET = object()
@dataclass
@@ -383,10 +184,6 @@ class ContextEvent:
completion_tokens: Number of tokens in the completion/response
cost_usd: Estimated cost in USD for this event
content_hash: SHA256 hash of content for duplicate detection
run_id: The team/flow/recorded run this event belongs to (1.1)
parent_seq: sequence_num of the enclosing (or, on *_end, the closed)
*_start event (1.1)
node_id: Graph node the event concerns, e.g. ``agent:Writer`` (1.1)
"""
event_type: ContextEventType
timestamp: float
@@ -402,9 +199,6 @@ class ContextEvent:
completion_tokens: int = 0
cost_usd: float = 0.0
content_hash: Optional[str] = None
run_id: Optional[str] = None
parent_seq: Optional[int] = None
node_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
@@ -427,12 +221,6 @@ class ContextEvent:
result["branch_id"] = self.branch_id
if self.content_hash is not None:
result["content_hash"] = self.content_hash
if self.run_id is not None:
result["run_id"] = self.run_id
if self.parent_seq is not None:
result["parent_seq"] = self.parent_seq
if self.node_id is not None:
result["node_id"] = self.node_id
return result
def to_json(self) -> str:
@@ -444,12 +232,12 @@ class ContextEvent:
"""Create ContextEvent from dictionary."""
event_type_str = data.get("event_type", "")
# Convert string to enum. An unknown type (written by a newer version)
# keeps its raw string rather than being relabelled as something else.
# Convert string to enum
try:
event_type = ContextEventType(event_type_str)
except ValueError:
event_type = _UnknownEventType(event_type_str)
# Fallback for unknown event types
event_type = ContextEventType.CONTEXT_SNAPSHOT
return cls(
event_type=event_type,
@@ -466,9 +254,6 @@ class ContextEvent:
completion_tokens=data.get("completion_tokens", 0),
cost_usd=data.get("cost_usd", 0.0),
content_hash=data.get("content_hash"),
run_id=data.get("run_id"),
parent_seq=data.get("parent_seq"),
node_id=data.get("node_id"),
)
@@ -564,28 +349,6 @@ class ContextListSink:
return iter(self._events)
def _innermost(stack: tuple, uid: int) -> Optional[int]:
"""Sequence number of the innermost open span owned by emitter ``uid``."""
for entry in reversed(stack):
if entry[0] == uid:
return entry[1]
return None
def _find_span(stack: tuple, uid: int, kind: str, key: Any) -> Optional[int]:
"""Index of the innermost open span matching kind+key (else kind only)."""
fallback = None
for i in range(len(stack) - 1, -1, -1):
entry = stack[i]
if entry[0] != uid or entry[2] != kind:
continue
if entry[3] == key:
return i
if fallback is None:
fallback = i
return fallback
class ContextTraceEmitter:
"""
Emitter for context trace events.
@@ -604,7 +367,7 @@ class ContextTraceEmitter:
emitter.session_end()
"""
__slots__ = ("_sink", "_session_id", "_enabled", "_redact", "_full_content", "_uid", "_counter", "_lock")
__slots__ = ("_sink", "_session_id", "_enabled", "_redact", "_sequence", "_branch_id", "_full_content")
# Default limits for truncation (can be overridden with full_content=True)
# Increased significantly to capture full search results and avoid false truncation
@@ -633,124 +396,40 @@ class ContextTraceEmitter:
self._session_id = session_id
self._enabled = enabled
self._redact = redact
self._sequence = 0
self._branch_id: Optional[str] = None
self._full_content = full_content
self._uid = next(_emitter_uids)
# itertools.count is atomic under the GIL; the lock additionally keeps
# the sink's append order equal to sequence order when Parallel/Loop
# branches emit from several threads at once (live tailers resume by
# sequence number, so an out-of-order line would be skipped).
self._counter = itertools.count()
self._lock = threading.RLock()
def _emit(self, event: ContextEvent, span_key: Any = _UNSET) -> None:
"""Internal emit with enabled check, sequence and span assignment.
Stamps ``sequence_num``, ``branch_id``, ``run_id`` and ``parent_seq``.
A ``*_start`` event (and ``approval_requested``) opens a span on the
per-context stack; the matching ``*_end`` closes it and points its
``parent_seq`` at the start it closes. Every other event points at the
innermost open span.
def _emit(self, event: ContextEvent) -> None:
"""Internal emit with enabled check and sequence assignment.
Exception-safe: sink errors are silently caught to prevent
tracing from crashing agent execution.
"""
if not self._enabled:
return
uid = self._uid
branch = _branch_var.get()
event.branch_id = branch[1] if branch is not None and branch[0] == uid else None
if event.run_id is None:
event.run_id = _run_id_var.get()
etype = event.event_type
value = etype.value if isinstance(etype, ContextEventType) else str(etype)
if span_key is _UNSET:
span_key = event.node_id or event.agent_name
with self._lock:
seq = next(self._counter)
event.sequence_num = seq
stack = _span_var.get()
kind = _SPAN_START.get(value)
if kind is not None:
if event.parent_seq is None:
event.parent_seq = _innermost(stack, uid)
_span_var.set(stack + ((uid, seq, kind, span_key),))
else:
kind = _SPAN_END.get(value)
if kind is not None:
idx = _find_span(stack, uid, kind, span_key)
if idx is not None:
event.parent_seq = stack[idx][1]
# Close it and anything of ours left open above it
# (a start whose end was skipped by an exception).
_span_var.set(stack[:idx] + tuple(e for e in stack[idx + 1:] if e[0] != uid))
elif event.parent_seq is None:
event.parent_seq = _innermost(stack, uid)
try:
self._sink.emit(event)
except Exception:
# Tracing should never crash agent execution
pass
event.sequence_num = self._sequence
event.branch_id = self._branch_id # Include branch context
self._sequence += 1
try:
self._sink.emit(event)
except Exception:
# Tracing should never crash agent execution
pass
def set_branch(self, branch_id: str) -> None:
"""Set current branch context for parallel execution tracking.
All subsequent events emitted from the current context (thread or
asyncio task) include this branch_id until cleared. Concurrent
branches each run in their own copied context, so they cannot
overwrite each other's branch.
All subsequent events will include this branch_id until cleared.
Args:
branch_id: Identifier for the parallel branch (e.g., 'parallel_0', 'loop_5')
"""
_branch_var.set((self._uid, branch_id))
self._branch_id = branch_id
def clear_branch(self) -> None:
"""Clear the current branch context."""
_branch_var.set(None)
def _preview(self, text: Any) -> Optional[str]:
"""Truncate to 500 chars and pass through redaction (for previews)."""
if text is None:
return None
text = text if isinstance(text, str) else str(text)
if self._redact:
from .redact import redact_string
text = redact_string(text[: _PREVIEW_LIMIT * 4])
return text[:_PREVIEW_LIMIT]
def _redact_args(self, args: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Approval args: sensitive keys redacted; text values previewed + redacted."""
if not args:
return {}
if not isinstance(args, dict):
return {"value": self._preview(args)}
args = self._redact_dict(args)
return {k: self._preview(v) if isinstance(v, str) else v for k, v in args.items()}
def _current_agent(self) -> Optional[str]:
"""Name of the innermost open agent span in this context, if any."""
uid = self._uid
for entry in reversed(_span_var.get()):
if entry[0] == uid and entry[2] == "agent":
key = entry[3]
if isinstance(key, str) and key.startswith("agent:"):
return key[len("agent:"):]
return key
return None
def _llm_call_open(self) -> bool:
"""True while a caller-emitted ``llm_request`` awaits its response.
The agent's OpenAI-client path emits llm_request/llm_response around
its whole dispatch, which may itself reach the LiteLLM path; the LLM
class checks this so the same call is not counted twice. The marker is
only honoured inside the span it was opened in, so one left behind by
an exception goes stale as soon as that span closes.
"""
mark = _llm_open_var.get()
if mark is None or mark[0] != self._uid:
return False
return mark[1] == _innermost(_span_var.get(), self._uid)
self._branch_id = None
def _redact_dict(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Redact sensitive data from dictionary if enabled."""
@@ -796,7 +475,6 @@ class ContextTraceEmitter:
session_id=self._session_id,
agent_name=agent_name,
data=metadata or {},
node_id=f"agent:{_agent_key(agent_name)}",
))
def agent_end(
@@ -813,7 +491,6 @@ class ContextTraceEmitter:
session_id=self._session_id,
agent_name=agent_name,
data=metadata or {},
node_id=f"agent:{_agent_key(agent_name)}",
))
def message_added(
@@ -863,7 +540,6 @@ class ContextTraceEmitter:
"tool_name": tool_name,
"tool_args": self._redact_dict(tool_args) if tool_args else {},
},
node_id=f"tool:{_agent_key(agent_name)}/{tool_name}",
))
def tool_call_end(
@@ -911,7 +587,6 @@ class ContextTraceEmitter:
"duration_ms": duration_ms,
"error": error,
},
node_id=f"tool:{_agent_key(agent_name)}/{tool_name}",
))
def _smart_truncate_result(self, result: str, tool_name: str) -> str:
@@ -1011,27 +686,11 @@ class ContextTraceEmitter:
"""
if not self._enabled:
return
event = self._llm_request_event(
agent_name, messages_count, tokens_used, tokens_budget, model, messages
)
self._emit(event)
# Mark the call open so the LLM class does not record it a second time.
# The previous marker is kept so a nested agent's call restores it.
_llm_open_var.set((self._uid, event.parent_seq, _llm_open_var.get()))
def _llm_request_event(
self,
agent_name: Optional[str],
messages_count: int,
tokens_used: int,
tokens_budget: int = 0,
model: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> ContextEvent:
data = {"model": model}
if messages is not None:
data["messages"] = messages
return ContextEvent(
self._emit(ContextEvent(
event_type=ContextEventType.LLM_REQUEST,
timestamp=time.time(),
session_id=self._session_id,
@@ -1040,8 +699,7 @@ class ContextTraceEmitter:
tokens_used=tokens_used,
tokens_budget=tokens_budget,
data=data,
node_id=f"agent:{_agent_key(agent_name)}",
)
))
def llm_response(
self,
@@ -1053,7 +711,6 @@ class ContextTraceEmitter:
prompt_tokens: int = 0,
completion_tokens: int = 0,
cost_usd: float = 0.0,
model: Optional[str] = None,
) -> None:
"""Emit LLM response event.
@@ -1066,30 +723,9 @@ class ContextTraceEmitter:
prompt_tokens: Number of tokens in the prompt/request
completion_tokens: Number of tokens in the completion/response
cost_usd: Estimated cost in USD for this LLM call
model: Model that served the call (stored as ``data.model``)
"""
if not self._enabled:
return
self._emit(self._llm_response_event(
agent_name, response_tokens, duration_ms, finish_reason,
response_content, prompt_tokens, completion_tokens, cost_usd, model,
))
mark = _llm_open_var.get()
if mark is not None and mark[0] == self._uid:
_llm_open_var.set(mark[2])
def _llm_response_event(
self,
agent_name: Optional[str],
response_tokens: int = 0,
duration_ms: float = 0.0,
finish_reason: Optional[str] = None,
response_content: Optional[str] = None,
prompt_tokens: int = 0,
completion_tokens: int = 0,
cost_usd: float = 0.0,
model: Optional[str] = None,
) -> ContextEvent:
# Use completion_tokens if provided, fallback to response_tokens for backward compat
actual_completion_tokens = completion_tokens if completion_tokens > 0 else response_tokens
@@ -1112,10 +748,8 @@ class ContextTraceEmitter:
data["response_content"] = f"{head}\n...[{len(response_content):,} chars, showing first/last portions]...\n{tail}"
else:
data["response_content"] = response_content
if model is not None:
data["model"] = model
return ContextEvent(
self._emit(ContextEvent(
event_type=ContextEventType.LLM_RESPONSE,
timestamp=time.time(),
session_id=self._session_id,
@@ -1124,8 +758,7 @@ class ContextTraceEmitter:
completion_tokens=actual_completion_tokens,
cost_usd=cost_usd,
data=data,
node_id=f"agent:{_agent_key(agent_name)}",
)
))
def context_snapshot(
self,
@@ -1157,16 +790,8 @@ class ContextTraceEmitter:
to_agent: str,
reason: Optional[str] = None,
context_passed: Optional[Dict[str, Any]] = None,
kind: str = "handoff",
node_id: Optional[str] = None,
) -> None:
"""Emit agent handoff event for tracking agent-to-agent flow.
Args:
kind: ``handoff`` (agent hands over), ``delegate`` (the hierarchical
manager assigns a task), ``route`` or ``spawn``.
node_id: Graph node concerned; defaults to the receiving agent.
"""
"""Emit agent handoff event for tracking agent-to-agent flow."""
if not self._enabled:
return
self._emit(ContextEvent(
@@ -1178,217 +803,10 @@ class ContextTraceEmitter:
"from_agent": from_agent,
"to_agent": to_agent,
"reason": reason,
"context_passed": self._redact_dict(context_passed) or {},
"kind": kind,
"context_passed": context_passed or {},
},
node_id=node_id or (f"agent:{to_agent}" if to_agent else None),
))
# ------------------------------------------------------------------
# Run structure (schema 1.1). Each method is a no-op when disabled.
# ------------------------------------------------------------------
def workflow_start(
self,
kind: str,
name: Optional[str],
process: str,
graph: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""A team, flow or agent run begins. ``graph`` is its static graph."""
if not self._enabled:
return
data: Dict[str, Any] = {"kind": kind, "name": name, "process": process}
if graph is not None:
data["graph"] = graph
if metadata:
data.update(metadata)
self._emit(ContextEvent(
event_type=ContextEventType.WORKFLOW_START,
timestamp=time.time(),
session_id=self._session_id,
data=data,
))
def workflow_end(self, status: str = "completed", duration_ms: float = 0.0) -> None:
"""The innermost open team/flow run ends."""
if not self._enabled:
return
self._emit(ContextEvent(
event_type=ContextEventType.WORKFLOW_END,
timestamp=time.time(),
session_id=self._session_id,
data={"status": status, "duration_ms": duration_ms},
))
def task_start(
self,
name: Optional[str],
description: Optional[str] = None,
agent: Optional[str] = None,
node_id: Optional[str] = None,
) -> None:
"""A team task starts (node_id ``task:<name or index>``)."""
if not self._enabled:
return
self._emit(ContextEvent(
event_type=ContextEventType.TASK_START,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent,
data={
"name": name,
"description_preview": self._preview(description),
"agent": agent,
},
node_id=node_id,
))
def task_end(
self,
name: Optional[str],
status: str,
retries: int = 0,
duration_ms: float = 0.0,
output: Any = None,
agent: Optional[str] = None,
node_id: Optional[str] = None,
) -> None:
"""A team task ends: ``completed``, ``failed`` or ``skipped``."""
if not self._enabled:
return
self._emit(ContextEvent(
event_type=ContextEventType.TASK_END,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent,
data={
"name": name,
"status": status,
"retries": retries,
"duration_ms": duration_ms,
"output_preview": self._preview(output),
},
node_id=node_id,
))
def step_start(
self,
name: Optional[str],
pattern: Optional[str] = None,
node_id: Optional[str] = None,
agent: Optional[str] = None,
) -> None:
"""An AgentFlow step (or a Route/Parallel/Loop/... pattern) starts."""
if not self._enabled:
return
data: Dict[str, Any] = {"name": name}
if pattern:
data["pattern"] = pattern
self._emit(ContextEvent(
event_type=ContextEventType.STEP_START,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent,
data=data,
node_id=node_id,
))
def step_end(
self,
name: Optional[str],
status: str,
duration_ms: float = 0.0,
output: Any = None,
route: Optional[str] = None,
node_id: Optional[str] = None,
agent: Optional[str] = None,
variables: Optional[Dict[str, Any]] = None,
) -> None:
"""A flow step ends: ``completed``, ``failed``, ``skipped`` or ``cached``.
``variables`` are the workflow variables the step itself set (a handler's
``StepResult.variables``); recorded redacted so a re-run that reuses the
step can restore them.
"""
if not self._enabled:
return
data: Dict[str, Any] = {
"name": name,
"status": status,
"duration_ms": duration_ms,
"output_preview": self._preview(output),
}
if route is not None:
data["route"] = route
if variables:
data["variables"] = self._redact_dict(dict(variables))
self._emit(ContextEvent(
event_type=ContextEventType.STEP_END,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent,
data=data,
node_id=node_id,
))
def approval_requested(
self,
approval_id: str,
tool: str,
args: Optional[Dict[str, Any]] = None,
risk: Optional[str] = None,
kind: str = "tool_call",
agent_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> None:
"""A person is asked to approve a tool call or a task's output."""
if not self._enabled:
return
self._emit(ContextEvent(
event_type=ContextEventType.APPROVAL_REQUESTED,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent_name,
data={
"approval_id": approval_id,
"tool": tool,
"args": self._redact_args(args),
"risk": risk,
"kind": kind,
},
node_id=node_id,
), span_key=approval_id)
def approval_decided(
self,
approval_id: str,
approved: bool,
reason: Optional[str] = None,
approver: Optional[str] = None,
modified: bool = False,
agent_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> None:
"""The decision for ``approval_id`` (parent_seq = the request)."""
if not self._enabled:
return
self._emit(ContextEvent(
event_type=ContextEventType.APPROVAL_DECIDED,
timestamp=time.time(),
session_id=self._session_id,
agent_name=agent_name,
data={
"approval_id": approval_id,
"approved": bool(approved),
"reason": reason,
"approver": approver,
"modified": bool(modified),
},
node_id=node_id,
), span_key=approval_id)
def memory_store(
self,
agent_name: str,
@@ -1,62 +0,0 @@
import os
import stat
import tempfile
from praisonaiagents._logging import get_logger
from ..tools.path_safety import resolve_within_root
logger = get_logger(__name__)
def _open_nofollow(path, flags, mode=0o666):
return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0), mode)
def write_output_file(path: str, content: str, create_dirs: bool = True) -> str | None:
"""Write *content* to *path* if it resolves inside the project root.
The root is ``PRAISONAI_PROJECT_ROOT`` or the cwd, matching ``Agent.output_file``.
Returns the resolved path, or ``None`` when the path escapes the root.
"""
project_root = os.environ.get("PRAISONAI_PROJECT_ROOT") or os.getcwd()
file_path = resolve_within_root(path, project_root)
if file_path is None:
logger.warning(
"Output file %r is outside project root %r; skipping save",
path,
project_root,
)
return None
if create_dirs:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "a", encoding="utf-8", opener=_open_nofollow) as f:
st = os.fstat(f.fileno())
if st.st_nlink == 1:
f.truncate(0)
f.write(content)
return file_path
# A hardlink may share its inode with a file outside the root, so swap in
# a new file under the same name instead of truncating the shared one.
fd, tmp = tempfile.mkstemp(
dir=os.path.dirname(file_path),
prefix=os.path.basename(file_path) + ".",
suffix=".tmp",
)
try:
mode = stat.S_IMODE(st.st_mode)
if hasattr(os, "fchown"):
try:
os.fchown(fd, -1, st.st_gid)
except OSError:
# Can't keep the original group; don't grant its bits to ours.
mode &= ~0o070
if hasattr(os, "fchmod"):
os.fchmod(fd, mode)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, file_path)
except BaseException:
os.unlink(tmp)
raise
return file_path
@@ -1,25 +1,13 @@
"""Render a run's DEFINITION as graph data and as a mermaid diagram.
"""Render a workflow's DEFINITION as a mermaid diagram.
PraisonAI could already diagram a captured execution trace
(``telemetry/performance_utils.py``), but that only exists once a flow has run
-- so a flow could only be pictured after it had already cost money. This
renders the structure from the definition, before anything executes, which is
renders the structure from the step list, before anything executes, which is
what CrewAI's ``flow.plot()`` gives its users.
One builder walks the definition and produces graph data
(``schema: "praisonai.graph/1"``) for an ``Agent``, an ``AgentTeam`` or an
``AgentFlow``. Node ids are derived from names and positions only (never a
uuid), so they are identical across processes and runs and the same ids appear
as ``node_id`` on replay trace events:
- ``agent:<name>``, ``task:<name or index>``, ``step:<name>``
(duplicates become ``#2``, ``#3`` in definition order),
``tool:<agent>/<tool>``, ``human:you``, ``manager:Manager``
- ``start:begin`` ("Brief") and ``end:done`` ("Delivered")
Mermaid rather than graphviz on purpose: it needs no new dependency, and both
GitHub and the docs render it inline. Stdlib only; nothing here is imported
when ``praisonaiagents`` is.
GitHub and the docs render it inline.
>>> print(flow.to_mermaid())
graph TD
@@ -28,25 +16,9 @@ when ``praisonaiagents`` is.
...
"""
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, List, Optional, Tuple
__all__ = [
"to_mermaid",
"flow_to_mermaid",
"to_graph",
"graph_to_mermaid",
"GRAPH_SCHEMA",
]
GRAPH_SCHEMA = "praisonai.graph/1"
START_ID = "start:begin"
END_ID = "end:done"
HUMAN_ID = "human:you"
MANAGER_ID = "manager:Manager"
_FLOW_CLASSES = {"AgentFlow"}
_TEAM_CLASSES = {"AgentTeam"}
__all__ = ["to_mermaid", "flow_to_mermaid"]
def _label(step: Any) -> str:
@@ -69,183 +41,23 @@ def _escape(text: str) -> str:
return str(text).replace('"', "'").replace("\n", " ")
def _class_names(obj: Any) -> set:
return {klass.__name__ for klass in type(obj).__mro__}
class _Builder:
def __init__(self) -> None:
self.lines: List[str] = ["graph TD"]
self._n = 0
def _tool_name(tool: Any) -> str:
if isinstance(tool, str):
return tool
if isinstance(tool, dict):
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
return str(function.get("name") or tool.get("name") or "tool")
for attr in ("name", "__name__"):
value = getattr(tool, attr, None)
if isinstance(value, str) and value:
return value
return type(tool).__name__
def _is_agent(obj: Any) -> bool:
# An unnamed Agent (``Agent(instructions=...)``) has ``name=None``; it is still an agent.
return hasattr(obj, "chat") and hasattr(obj, "name")
# ---------------------------------------------------------------------------
# Graph builder
# ---------------------------------------------------------------------------
class _Graph:
"""Accumulates nodes/edges, and the mermaid draw order for flows.
``ops`` records exactly the nodes and edges the legacy flow diagram drew,
in the order it drew them, so ``flow_to_mermaid`` stays byte-identical
while the graph data can carry more (agents, tools, pattern groups).
"""
def __init__(self, kind: str, name: Optional[str], process: str) -> None:
self.kind = kind
self.name = name
self.process = process
self.nodes: Dict[str, Dict[str, Any]] = {}
self.edges: Dict[str, Dict[str, Any]] = {}
self.ops: List[tuple] = []
# id(definition object) -> node id, first occurrence wins. Used at run
# time to stamp node_id on step events.
self.ids: Dict[int, str] = {}
self._seen: Dict[str, int] = {}
# edge id -> distinct route labels, joined only for display.
self._labels: Dict[str, List[str]] = {}
def unique(self, base: str) -> str:
"""``base`` the first time, then ``base#2``, ``base#3`` ..."""
count = self._seen.get(base, 0) + 1
self._seen[base] = count
return base if count == 1 else f"{base}#{count}"
def node(
self,
node_id: str,
kind: str,
label: str,
parent: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
shape: Optional[str] = None,
) -> str:
if node_id not in self.nodes:
self.nodes[node_id] = {
"id": node_id,
"kind": kind,
"label": label,
"parent": parent,
"meta": dict(meta or {}),
}
if shape is not None:
self.ops.append(("node", node_id, shape))
def node(self, shape: str) -> str:
node_id = f"n{self._n}"
self._n += 1
self.lines.append(f" {node_id}{shape}")
return node_id
def edge(
self,
source: str,
target: str,
kind: str,
label: Optional[str] = None,
draw: bool = False,
) -> None:
edge_id = f"{source}->{target}:{kind}"
if edge_id not in self.edges:
self.edges[edge_id] = {
"id": edge_id,
"source": source,
"target": target,
"kind": kind,
"label": label,
}
self._labels[edge_id] = [label] if label else []
elif label:
# Two conditions to the same target share one edge; keep both labels.
labels = self._labels[edge_id]
if label not in labels:
labels.append(label)
self.edges[edge_id]["label"] = " / ".join(labels)
if draw:
self.ops.append(("edge", source, target, label))
def has_edge(self, source: str, target: str, kind: str) -> bool:
return f"{source}->{target}:{kind}" in self.edges
def register(self, obj: Any, node_id: str) -> None:
self.ids.setdefault(id(obj), node_id)
def to_dict(self) -> Dict[str, Any]:
return {
"schema": GRAPH_SCHEMA,
"kind": self.kind,
"name": self.name,
"process": self.process,
"nodes": list(self.nodes.values()),
"edges": list(self.edges.values()),
}
def ops_to_mermaid(self) -> str:
lines = ["graph TD"]
short: Dict[str, str] = {}
for op in self.ops:
if op[0] == "node":
ref = f"n{len(short)}"
short[op[1]] = ref
lines.append(f" {ref}{op[2]}")
else:
_, source, target, label = op
arrow = f' -->|"{_escape(label)}"| ' if label else " --> "
lines.append(f" {short[source]}{arrow}{short[target]}")
return "\n".join(lines)
def edge(self, src: str, dst: str, label: Optional[str] = None) -> None:
arrow = f' -->|"{_escape(label)}"| ' if label else " --> "
self.lines.append(f" {src}{arrow}{dst}")
def _add_agent(g: _Graph, agent: Any, parent: Optional[str] = None, _seen: Optional[set] = None) -> str:
"""Add an agent node with its tools and hand-off targets; return its id."""
from ..trace.context_events import _agent_key
name = getattr(agent, "name", None) or type(agent).__name__ # label
key = _agent_key(getattr(agent, "name", None)) # the id the trace uses
agent_id = f"agent:{key}"
seen = _seen if _seen is not None else set()
if agent_id in seen:
return agent_id
seen.add(agent_id)
g.node(agent_id, "agent", name, parent=parent, meta={"role": getattr(agent, "role", None)})
targets = []
handoff_tools = set()
for handoff in list(getattr(agent, "handoffs", None) or []):
target = handoff if _is_agent(handoff) else getattr(handoff, "agent", None)
if target is None or not getattr(target, "name", None):
continue
targets.append(target)
tool_name = getattr(handoff, "tool_name", None) if not _is_agent(handoff) else None
if tool_name is None:
from ..agent.handoff import Handoff
tool_name = Handoff(agent=target).tool_name
handoff_tools.add(tool_name)
for tool in list(getattr(agent, "tools", None) or []):
tool_name = _tool_name(tool)
if tool_name in handoff_tools:
continue # drawn as the hand-off edge, not as a tool
tool_id = f"tool:{key}/{tool_name}"
g.node(tool_id, "tool", tool_name, parent=parent)
g.edge(agent_id, tool_id, "uses")
for target in targets:
target_id = _add_agent(g, target, parent=parent, _seen=seen)
g.edge(agent_id, target_id, "handoff")
return agent_id
# ---------------------------------------------------------------------------
# AgentFlow
# ---------------------------------------------------------------------------
def _flow_branch(steps: Any, g: _Graph, parent: Optional[str]) -> Optional[Tuple[str, str]]:
def _branch(steps: Any, b: "_Builder") -> Optional[Tuple[str, str]]:
"""Render a sub-sequence of steps in isolation.
Returns the ``(entry, exit)`` pair for the whole sub-sequence, or ``None``
@@ -256,7 +68,7 @@ def _flow_branch(steps: Any, g: _Graph, parent: Optional[str]) -> Optional[Tuple
entry: Optional[str] = None
tail: Optional[str] = None
for inner in steps or []:
rendered = _flow_step(inner, g, tail, parent)
rendered = _render(inner, b, tail)
if rendered is None:
continue
inner_entry, inner_exit = rendered
@@ -268,15 +80,7 @@ def _flow_branch(steps: Any, g: _Graph, parent: Optional[str]) -> Optional[Tuple
return entry, tail
def _pattern_group(g: _Graph, step: Any, pattern: str, parent: Optional[str], label: str, meta: Dict[str, Any]) -> Tuple[str, str]:
"""The ``step:<name>`` node that groups a pattern's parts; returns (id, suffix)."""
group = g.unique(f"step:{label}")
g.node(group, "step", label, parent=parent, meta={"pattern": pattern, **meta})
g.register(step, group)
return group, group[len("step:"):]
def _flow_step(step: Any, g: _Graph, prev: Optional[str], parent: Optional[str]) -> Optional[Tuple[str, str]]:
def _render(step: Any, b: _Builder, prev: Optional[str]) -> Optional[Tuple[str, str]]:
"""Render one step, returning its ``(entry, exit)`` nodes.
``entry`` is what an incoming edge should point at; ``exit`` is what the
@@ -285,96 +89,82 @@ def _flow_step(step: Any, g: _Graph, prev: Optional[str], parent: Optional[str])
its join).
"""
cls = type(step).__name__
name = _escape(_label(step))
if cls == "If":
condition = getattr(step, "condition", "") or "if"
group, suffix = _pattern_group(g, step, "if", parent, "if", {"condition": str(condition)})
decision = g.node(f"decision:{suffix}", "decision", str(condition), parent=group,
shape=f'{{"{_escape(condition)}"}}')
decision = b.node(f'{{"{_escape(getattr(step, "condition", "") or "if")}"}}')
if prev:
g.edge(prev, decision, "next", draw=True)
b.edge(prev, decision)
ends = []
for branch, edge_label in (("then_steps", "true"), ("else_steps", "false")):
rendered = _flow_branch(getattr(step, branch, None), g, group)
rendered = _branch(getattr(step, branch, None), b)
if rendered:
branch_entry, branch_exit = rendered
g.edge(decision, branch_entry, "routes", edge_label, draw=True)
b.edge(decision, branch_entry, edge_label)
ends.append(branch_exit)
join = g.node(f"join:{suffix}", "join", "join", parent=group, shape='(("join"))')
join = b.node('(("join"))')
for end in ends or [decision]:
g.edge(end, join, "next", draw=True)
b.edge(end, join)
return decision, join
if cls == "Route":
group, suffix = _pattern_group(g, step, "route", parent, "route", {})
router = g.node(f"decision:{suffix}", "decision", "route", parent=group, shape='{"route"}')
router = b.node(f'{{"route"}}')
if prev:
g.edge(prev, router, "next", draw=True)
b.edge(prev, router)
ends = []
routes = getattr(step, "routes", None) or {}
for key, steps in routes.items():
rendered = _flow_branch(steps, g, group)
rendered = _branch(steps, b)
if rendered:
branch_entry, branch_exit = rendered
g.edge(router, branch_entry, "routes", key, draw=True)
b.edge(router, branch_entry, key)
ends.append(branch_exit)
# ``Route.__init__`` mirrors ``routes["default"]`` into ``.default``;
# only render ``.default`` when it was not already drawn as a key above.
if "default" not in routes:
rendered = _flow_branch(getattr(step, "default", None), g, group)
rendered = _branch(getattr(step, "default", None), b)
if rendered:
branch_entry, branch_exit = rendered
g.edge(router, branch_entry, "routes", "default", draw=True)
b.edge(router, branch_entry, "default")
ends.append(branch_exit)
join = g.node(f"join:{suffix}", "join", "join", parent=group, shape='(("join"))')
join = b.node('(("join"))')
for end in ends or [router]:
g.edge(end, join, "next", draw=True)
b.edge(end, join)
return router, join
if cls == "Parallel":
group, suffix = _pattern_group(g, step, "parallel", parent, "parallel", {})
fork = g.node(f"fork:{suffix}", "fork", "fork", parent=group, shape='(("fork"))')
fork = b.node('(("fork"))')
if prev:
g.edge(prev, fork, "next", draw=True)
b.edge(prev, fork)
ends = []
for inner in getattr(step, "steps", None) or []:
rendered = _flow_step(inner, g, None, group)
rendered = _render(inner, b, None)
if rendered:
branch_entry, branch_exit = rendered
g.edge(fork, branch_entry, "parallel", draw=True)
b.edge(fork, branch_entry)
ends.append(branch_exit)
join = g.node(f"join:{suffix}", "join", "join", parent=group, shape='(("join"))')
join = b.node('(("join"))')
for end in ends or [fork]:
g.edge(end, join, "next", draw=True)
b.edge(end, join)
return fork, join
if cls in ("Loop", "Repeat"):
pattern = cls.lower()
inner = getattr(step, "step", None)
# ``Repeat(step=[a, b])`` runs each member, as ``_execute_repeat`` does.
inner_steps = getattr(step, "steps", None) or (
list(inner) if isinstance(inner, (list, tuple))
else [inner] if inner is not None else []
[getattr(step, "step")] if getattr(step, "step", None) is not None else []
)
over = getattr(step, "over", None)
iterations = getattr(step, "max_iterations", None)
meta = {}
if over:
meta["over"] = str(over)
if iterations:
meta["max_iterations"] = iterations
group, _ = _pattern_group(g, step, pattern, parent, pattern, meta)
tail = prev
first = None
for inner in inner_steps:
rendered = _flow_step(inner, g, tail, group)
rendered = _render(inner, b, tail)
if rendered:
inner_entry, inner_exit = rendered
first = first or inner_entry
tail = inner_exit
if first and tail:
over = getattr(step, "over", None)
iterations = getattr(step, "max_iterations", None)
back = f"over {over}" if over else (f"up to {iterations}x" if iterations else "repeat")
g.edge(tail, first, "loop", back, draw=True)
b.edge(tail, first, back)
if first is None or tail is None:
return None
return first, tail
@@ -384,300 +174,33 @@ def _flow_step(step: Any, g: _Graph, prev: Optional[str], parent: Optional[str])
# them inline rather than as an opaque node. A recipe include (by name)
# is only resolvable at runtime, so it stays a single node.
workflow = getattr(step, "workflow", None)
recipe = getattr(step, "recipe", None)
meta = {"recipe": recipe} if recipe else {}
if workflow is not None and getattr(workflow, "name", None):
meta["workflow"] = workflow.name
group, _ = _pattern_group(g, step, "include", parent, "include", meta)
if workflow is not None:
rendered = _flow_branch(getattr(workflow, "steps", None), g, group)
rendered = _branch(getattr(workflow, "steps", None), b)
if rendered:
branch_entry, branch_exit = rendered
if prev:
g.edge(prev, branch_entry, "next", draw=True)
b.edge(prev, branch_entry)
return branch_entry, branch_exit
# Unexpanded: the group node itself is the drawn step.
g.ops.append(("node", group, f'["{_escape(_label(step))}"]'))
if prev:
g.edge(prev, group, "next", draw=True)
return group, group
label = _label(step)
node_id = g.unique(f"step:{label}")
meta: Dict[str, Any] = {}
if cls == "Discussion":
meta["pattern"] = "discussion"
g.node(node_id, "step", label, parent=parent, meta=meta, shape=f'["{_escape(label)}"]')
g.register(step, node_id)
node = b.node(f'["{name}"]')
if prev:
g.edge(prev, node_id, "next", draw=True)
# Graph-only detail: who does the step, and with which tools.
if cls == "Discussion":
for speaker in getattr(step, "agents", None) or []:
if _is_agent(speaker):
g.edge(node_id, _add_agent(g, speaker), "assigned")
g.register(speaker, node_id)
elif _is_agent(step):
g.edge(node_id, _add_agent(g, step), "assigned")
elif _is_agent(getattr(step, "agent", None)):
g.edge(node_id, _add_agent(g, step.agent), "assigned")
return node_id, node_id
def _build_flow(steps: Any, name: Optional[str], kind: str = "flow", process: str = "flow") -> _Graph:
g = _Graph(kind, name, process)
start = g.node(START_ID, "start", "Brief",
shape='([" start "])' if not name else f'(["{_escape(name)}"])')
prev: Optional[str] = start
for step in (steps or []):
rendered = _flow_step(step, g, prev, None)
if rendered:
prev = rendered[1]
end = g.node(END_ID, "end", "Delivered", shape='([" end "])')
if prev:
g.edge(prev, end, "next", draw=True)
return g
def _flow_process(flow: Any) -> str:
return "hierarchical" if getattr(flow, "process", None) == "hierarchical" else "flow"
def _flow_graph(flow: Any) -> _Graph:
return _build_flow(getattr(flow, "steps", None), getattr(flow, "name", None),
process=_flow_process(flow))
# ---------------------------------------------------------------------------
# AgentTeam
# ---------------------------------------------------------------------------
def _is_synthetic_manager(task: Any) -> bool:
"""The hierarchical process injects a ``manager_task`` run by "Manager"."""
agent = getattr(task, "agent", None)
return getattr(task, "name", None) == "manager_task" and getattr(agent, "name", None) == "Manager"
def _team_tasks(team: Any) -> List[Any]:
tasks = getattr(team, "tasks", None) or []
if isinstance(tasks, dict):
return list(tasks.values())
return list(tasks)
def task_node_ids(tasks: List[Any]) -> List[str]:
"""Stable node ids for a team's tasks, aligned with ``tasks``.
``task:<name>`` (or ``task:<index>`` for an unnamed task), duplicates
suffixed ``#2``... The hierarchical synthetic manager task maps to
``manager:Manager``.
"""
seen: Dict[str, int] = {}
ids = []
for index, task in enumerate(tasks):
if _is_synthetic_manager(task):
ids.append(MANAGER_ID)
continue
name = getattr(task, "name", None)
base = f"task:{name}" if name else f"task:{index}"
count = seen.get(base, 0) + 1
seen[base] = count
ids.append(base if count == 1 else f"{base}#{count}")
return ids
def task_node_id(tasks: Any, task: Any) -> Optional[str]:
"""Node id of ``task`` within a team's ``tasks`` (dict or list)."""
if isinstance(tasks, dict):
tasks = list(tasks.values())
tasks = list(tasks or [])
for task_obj, node_id in zip(tasks, task_node_ids(tasks)):
if task_obj is task:
return node_id
name = getattr(task, "name", None)
return f"task:{name}" if name else None
def _team_graph(team: Any) -> _Graph:
process = getattr(team, "process", None) or "sequential"
hierarchical = process == "hierarchical"
g = _Graph("team", getattr(team, "name", None), process)
all_tasks = _team_tasks(team)
ids = task_node_ids(all_tasks)
pairs = [(t, i) for t, i in zip(all_tasks, ids) if not _is_synthetic_manager(t)]
by_name = {}
for task, node_id in pairs:
if getattr(task, "name", None):
by_name.setdefault(task.name, node_id)
g.node(START_ID, "start", "Brief")
if hierarchical:
g.node(MANAGER_ID, "manager", "Manager")
for agent in getattr(team, "agents", None) or []:
if getattr(agent, "name", None):
_add_agent(g, agent)
if any(getattr(t, "human_input", False) for t, _ in pairs):
g.node(HUMAN_ID, "human", "You")
for index, (task, node_id) in enumerate(pairs):
label = getattr(task, "name", None) or (
(getattr(task, "description", None) or "")[:60] or f"Task {index + 1}"
)
agent = getattr(task, "agent", None)
meta: Dict[str, Any] = {}
if getattr(task, "task_type", None) not in (None, "task"):
meta["task_type"] = task.task_type
if getattr(task, "human_input", False):
meta["human_input"] = True
g.node(node_id, "task", str(label), meta=meta)
g.register(task, node_id)
if agent is not None and getattr(agent, "name", None):
g.edge(node_id, _add_agent(g, agent), "assigned")
if getattr(task, "human_input", False):
g.edge(HUMAN_ID, node_id, "reviews")
if hierarchical:
g.edge(START_ID, MANAGER_ID, "next")
for agent in getattr(team, "agents", None) or []:
if getattr(agent, "name", None):
g.edge(MANAGER_ID, f"agent:{agent.name}", "delegates")
g.edge(MANAGER_ID, END_ID, "next")
elif process == "workflow" and pairs:
starts = [n for t, n in pairs if getattr(t, "is_start", False)] or [pairs[0][1]]
for node_id in starts:
g.edge(START_ID, node_id, "next")
for task, node_id in pairs:
condition = getattr(task, "condition", None) or {}
followers = [x for x in (getattr(task, "next_tasks", None) or []) if x in by_name]
routed = False
# Unified when/then_task/else_task routing, which execution
# checks before condition/next_tasks.
for attr, branch in (("then_task", str(getattr(task, "when", None) or "then")),
("else_task", "else")):
target = getattr(task, attr, None)
if isinstance(target, str) and target in by_name:
g.edge(node_id, by_name[target], "routes", branch)
routed = True
if isinstance(condition, dict):
for key, targets in condition.items():
for target in (targets if isinstance(targets, (list, tuple)) else [targets]):
if target in by_name:
g.edge(node_id, by_name[target], "routes", str(key))
routed = True
elif target in ("exit", "end", "done"):
g.edge(node_id, END_ID, "routes", str(key))
routed = True
for target in followers:
g.edge(node_id, by_name[target], "next")
if not followers and not routed:
g.edge(node_id, END_ID, "next")
else:
prev = START_ID
for _, node_id in pairs:
g.edge(prev, node_id, "next")
prev = node_id
g.edge(prev, END_ID, "next")
# Explicit data dependencies (context=/depends_on=), unless the same pair
# is already joined by a control edge (sequential teams add both).
task_ids = {id(t): n for t, n in pairs}
for task, node_id in pairs:
for dep in getattr(task, "context", None) or []:
dep_id = task_ids.get(id(dep))
if dep_id and dep_id != node_id and not g.has_edge(dep_id, node_id, "next"):
g.edge(dep_id, node_id, "depends_on")
g.node(END_ID, "end", "Delivered")
return g
def _agent_graph(agent: Any) -> _Graph:
g = _Graph("agent", getattr(agent, "name", None), "sequential")
g.node(START_ID, "start", "Brief")
agent_id = _add_agent(g, agent)
g.register(agent, agent_id)
g.edge(START_ID, agent_id, "next")
g.node(END_ID, "end", "Delivered")
g.edge(agent_id, END_ID, "next")
return g
def _graph_for(obj: Any) -> _Graph:
names = _class_names(obj)
if names & _FLOW_CLASSES:
return _flow_graph(obj)
if names & _TEAM_CLASSES:
return _team_graph(obj)
if isinstance(obj, (list, tuple)):
return _build_flow(obj, None)
if _is_agent(obj):
return _agent_graph(obj)
if hasattr(obj, "steps"):
return _flow_graph(obj)
if hasattr(obj, "tasks") and hasattr(obj, "agents"):
return _team_graph(obj)
raise TypeError(f"to_graph() expects an Agent, AgentTeam or AgentFlow, got {type(obj).__name__}")
def to_graph(obj: Any) -> Dict[str, Any]:
"""Graph data (``praisonai.graph/1``) for an Agent, AgentTeam or AgentFlow."""
return _graph_for(obj).to_dict()
# ---------------------------------------------------------------------------
# Mermaid
# ---------------------------------------------------------------------------
_SHAPES = {
"start": '(["{}"])',
"end": '(["{}"])',
"decision": '{{"{}"}}',
"fork": '(("{}"))',
"join": '(("{}"))',
"manager": '{{{{"{}"}}}}',
"human": '>"{}"]',
"tool": '[["{}"]]',
}
def graph_to_mermaid(graph: Dict[str, Any]) -> str:
"""Render ``praisonai.graph/1`` data as a mermaid ``graph TD``.
Group nodes (a node other nodes name as ``parent``) are containers, not
boxes, so they and their edges are left out of the picture.
"""
nodes = list(graph.get("nodes") or [])
parents = {n.get("parent") for n in nodes if n.get("parent")}
short: Dict[str, str] = {}
lines = ["graph TD"]
for node in nodes:
if node["id"] in parents:
continue
ref = f"n{len(short)}"
short[node["id"]] = ref
shape = _SHAPES.get(node.get("kind"), '["{}"]')
lines.append(f" {ref}{shape.format(_escape(node.get('label') or node['id']))}")
for edge in graph.get("edges") or []:
source, target = short.get(edge["source"]), short.get(edge["target"])
if source is None or target is None:
continue
kind = edge.get("kind")
label = edge.get("label")
if kind in ("next", "parallel"):
arrow = f' -->|"{_escape(label)}"| ' if label else " --> "
elif kind in ("routes", "loop", "handoff", "delegates"):
arrow = f' -->|"{_escape(label or kind)}"| '
else:
arrow = f' -.->|"{_escape(label or kind)}"| '
lines.append(f" {source}{arrow}{target}")
return "\n".join(lines)
b.edge(prev, node)
return node, node
def to_mermaid(steps: Any, name: Optional[str] = None) -> str:
"""Render a list of workflow steps as a mermaid ``graph TD``."""
return _build_flow(steps, name).ops_to_mermaid()
b = _Builder()
start = b.node('([" start "])' if not name else f'(["{_escape(name)}"])')
prev: Optional[str] = start
for step in (steps or []):
rendered = _render(step, b, prev)
if rendered:
prev = rendered[1]
end = b.node('([" end "])')
if prev:
b.edge(prev, end)
return "\n".join(b.lines)
def flow_to_mermaid(flow: Any) -> str:
@@ -725,115 +725,6 @@ def include(recipe: Optional[str] = None, workflow: Optional[Any] = None, input:
# Maximum nesting depth for patterns (prevents stack overflow)
MAX_NESTING_DEPTH = 5
# ---------------------------------------------------------------------------
# Replay step events (step_start / step_end). Every helper is a no-op returning
# None when the context emitter is disabled, so an untraced run pays one
# attribute check per step.
# ---------------------------------------------------------------------------
# The innermost pattern span (Route/If) so it can record the branch it chose.
_replay_step_span: "contextvars.ContextVar[Optional[dict]]" = contextvars.ContextVar(
"praisonai_replay_step_span", default=None
)
def _replay_step_begin(flow, step, name=None, pattern=None):
"""Emit ``step_start`` for a definition object; returns a span or None."""
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled:
return None
node_id = (getattr(flow, "_replay_node_ids", None) or {}).get(id(step))
if name is None:
if node_id is not None:
name = node_id[len("step:"):] if node_id.startswith("step:") else node_id
else:
from .diagram import _label
name = pattern or _label(step)
if node_id is None:
node_id = f"step:{name}"
agent = getattr(step, "agent", None) if not hasattr(step, "chat") else step
agent = getattr(agent, "name", None) if agent is not None else None
emitter.step_start(name, pattern=pattern, node_id=node_id, agent=agent)
return {"emitter": emitter, "name": name, "node_id": node_id, "agent": agent,
"started": time.perf_counter()}
def _differs(a: Any, b: Any) -> bool:
"""``a != b`` that treats a failing or ambiguous comparison as "changed"."""
try:
return bool(a != b)
except Exception:
return True
def _snapshot_scope(span, variables):
"""Before a recorded leaf step really runs (not skipped or cached), keep a
per-value deep copy of the scope on its span, so a list or dict the step
changes in place still counts as set by it; only an uncopyable value is
shared by reference."""
if span is not None:
span["before"] = _isolate_scope(variables)
def _replay_step_end(span, status, output=None, variables=None):
"""Emit ``step_end`` for a span from :func:`_replay_step_begin`."""
if span is None:
return
span["emitter"].step_end(
span["name"],
status,
duration_ms=(time.perf_counter() - span["started"]) * 1000,
output=output,
route=span.get("route"),
node_id=span["node_id"],
agent=span["agent"],
variables=variables,
)
def _replay_note_route(label):
"""Record which branch the enclosing Route/If chose (tracing on only)."""
span = _replay_step_span.get()
if span is not None:
span["route"] = label
def _replay_pattern(pattern):
"""Wrap a pattern executor (``_execute_route`` ...) in step_start/step_end.
Top-level and nested patterns both reach the executor, so wrapping it here
covers every nesting level once.
"""
def decorate(execute):
def wrapper(self, step, *args, **kwargs):
name = getattr(step, "name", None) if pattern == "discussion" else None
span = _replay_step_begin(self, step, name, pattern)
if span is None:
return execute(self, step, *args, **kwargs)
token = _replay_step_span.set(span)
try:
result = execute(self, step, *args, **kwargs)
except BaseException:
_replay_step_end(span, "failed")
raise
finally:
_replay_step_span.reset(token)
is_dict = isinstance(result, dict)
_replay_step_end(
span,
"failed" if is_dict and result.get("stop") else "completed",
result.get("output") if is_dict else None,
)
return result
wrapper.__name__ = execute.__name__
wrapper.__qualname__ = execute.__qualname__
wrapper.__doc__ = execute.__doc__
wrapper.__wrapped__ = execute
return wrapper
return decorate
@dataclass
class If:
"""
@@ -1573,26 +1464,19 @@ class AgentFlow:
"separate Workflow instance per concurrent run."
)
try:
from ..trace.context_events import _workflow_scope
_process = "hierarchical" if self.process == "hierarchical" else "flow"
with _workflow_scope("flow", self.name, _process, graph_fn=self._replay_graph) as _run:
if self.tools_run_on is None:
result = self._run_impl(input, llm, verbose, stream)
else:
# One sandbox for the whole run; torn down even if a step raises.
with self._shared_compute() as shared:
shared.attach(self._collect_agents())
# Expose the live sandbox so agents created *during* the run
# (e.g. an action-only step's temporary agent) can be attached
# too, instead of silently executing tools locally.
self._active_shared_compute = shared
try:
result = self._run_impl(input, llm, verbose, stream)
finally:
self._active_shared_compute = None
if _run is not None and isinstance(result, dict) and result.get("status") == "failed":
_run["status"] = "failed"
return result
if self.tools_run_on is None:
return self._run_impl(input, llm, verbose, stream)
# One sandbox for the whole run; torn down even if a step raises.
with self._shared_compute() as shared:
shared.attach(self._collect_agents())
# Expose the live sandbox so agents created *during* the run
# (e.g. an action-only step's temporary agent) can be attached
# too, instead of silently executing tools locally.
self._active_shared_compute = shared
try:
return self._run_impl(input, llm, verbose, stream)
finally:
self._active_shared_compute = None
finally:
# Restore the generation the caller's context carried before this
# run stamped its own, so a nested Workflow.run() (Include) does not
@@ -1614,15 +1498,6 @@ class AgentFlow:
from .diagram import flow_to_mermaid
return flow_to_mermaid(self)
def to_graph(self) -> Dict[str, Any]:
"""This flow's definition as graph data (``praisonai.graph/1``).
Node ids are stable across processes (derived from step names and
positions), and are the ``node_id`` values replay trace events carry.
"""
from .diagram import to_graph
return to_graph(self)
@property
def state(self):
"""The flow's variables as the declared ``state_model``.
@@ -1726,35 +1601,6 @@ class AgentFlow:
visit(self.steps)
return found
def _notify_step_start(self, raw_step: Any, step: Any, context: Any) -> Optional[dict]:
"""Emit replay ``step_start`` then call ``on_step_start``; returns the span."""
span = _replay_step_begin(self, raw_step, step.name)
if self.on_step_start:
try:
self.on_step_start(step.name, context)
except Exception as e:
logger.error(f"on_step_start callback failed: {e}")
return span
def _notify_step_complete(
self, span: Optional[dict], step_name: str, step_result: Any, status: str, output: Any = None,
variables: Optional[Dict[str, Any]] = None,
) -> None:
"""Call ``on_step_complete`` then emit replay ``step_end``."""
if self.on_step_complete:
try:
self.on_step_complete(step_name, step_result)
except Exception as e:
logger.error(f"on_step_complete callback failed: {e}")
_replay_step_end(span, status, output, variables)
def _replay_graph(self) -> Dict[str, Any]:
"""Graph for ``workflow_start``; also maps step objects to node ids."""
from .diagram import _flow_graph
graph = _flow_graph(self)
self._replay_node_ids = graph.ids
return graph.to_dict()
def _run_impl(
self,
input: str = "",
@@ -1975,7 +1821,6 @@ class AgentFlow:
continue
# Normalize single step
raw_step = step
step = self._normalize_single_step(step, i)
# Create context for handlers
@@ -1994,7 +1839,11 @@ class AgentFlow:
# Preserve the preparation hook before the gate: it may populate
# context variables used by should_run, including on cached runs.
_replay = self._notify_step_start(raw_step, step, context)
if self.on_step_start:
try:
self.on_step_start(step.name, context)
except Exception as e:
logger.error(f"on_step_start callback failed: {e}")
# Check should_run condition
if step.should_run:
@@ -2005,9 +1854,11 @@ class AgentFlow:
if hasattr(step, 'status'):
step.status = "skipped"
self.step_statuses[step.name] = "skipped"
self._notify_step_complete(
_replay, step.name, StepResult(output="", skipped=True), "skipped",
)
if self.on_step_complete:
try:
self.on_step_complete(step.name, StepResult(output="", skipped=True))
except Exception as e:
logger.error(f"on_step_complete callback failed: {e}")
i += 1
continue
except Exception as e:
@@ -2053,16 +1904,16 @@ class AgentFlow:
step.status = cached_record["status"]
if _cached.get("variables"):
all_variables.update(_cached["variables"])
# The cache also holds the step's output variable (its full
# output): trace only what the handler set, like a cold run.
_out_var = step.output_variable or f"{step.name}_output"
self._notify_step_complete(
_replay, step.name, StepResult(
output=previous_output or "",
stop_workflow=bool(_cached.get("stop")),
), "cached", previous_output,
variables={k: v for k, v in (_cached.get("variables") or {}).items() if k != _out_var},
)
if self.on_step_complete:
try:
self.on_step_complete(
step.name, StepResult(
output=previous_output or "",
stop_workflow=bool(_cached.get("stop")),
),
)
except Exception as e:
logger.error(f"on_step_complete callback failed: {e}")
if _cached.get("stop"):
if verbose:
print(f"🛑 Workflow stopped at: {step.name}")
@@ -2082,8 +1933,6 @@ class AgentFlow:
validation_feedback = None
guardrail_failed = False
cached_variable_updates = {} if _cache_key is not None else None
# Variables this step set (StepResult.variables), for replay's step_end.
step_variable_updates: Dict[str, Any] = {}
while retry_count <= max_retries:
step_error = None
@@ -2106,7 +1955,6 @@ class AgentFlow:
# accepted attempt that wrote {a} leaves both
# keys in the live run, so both must be cached.
all_variables.update(result.variables)
step_variable_updates.update(result.variables)
if cached_variable_updates is not None:
cached_variable_updates.update(result.variables)
else:
@@ -2348,10 +2196,14 @@ class AgentFlow:
})
# Still notify the step-complete callback so lifecycle observers
# see the (failed) terminal step before the workflow aborts.
self._notify_step_complete(
_replay, step.name,
StepResult(output=output or "", stop_workflow=True), "failed", output,
)
if self.on_step_complete:
try:
self.on_step_complete(
step.name,
StepResult(output=output or "", stop_workflow=True),
)
except Exception as e:
logger.error(f"on_step_complete callback failed: {e}")
if verbose:
print(f"🛑 Workflow stopped: step '{step.name}' failed (on_error='stop')")
break
@@ -2360,21 +2212,25 @@ class AgentFlow:
step_result = StepResult(output=output or "", stop_workflow=stop)
# Call on_step_complete callback
self._notify_step_complete(
_replay, step.name, step_result,
"failed" if step_failed else "completed", output,
variables=step_variable_updates,
)
if self.on_step_complete:
try:
self.on_step_complete(step.name, step_result)
except Exception as e:
logger.error(f"on_step_complete callback failed: {e}")
# Handle output_file - save output to file
if hasattr(step, 'output_file') and step.output_file and output:
try:
from ..utils.output_file import write_output_file
import os
output_path = step.output_file
# Substitute variables in path
for key, value in all_variables.items():
output_path = output_path.replace(f"{{{{{key}}}}}", str(value))
if write_output_file(output_path, str(output)) and verbose:
# Create directory if needed
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
with open(output_path, "w") as f:
f.write(str(output))
if verbose:
print(f"📁 Saved output to: {output_path}")
except Exception as e:
logger.error(f"Failed to save output to file: {e}")
@@ -3128,57 +2984,10 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
# them on every run so their conditions and nested Task gates execute;
# their individual leaves can still reuse cached results.
if isinstance(step, (Loop, Parallel, Route, Repeat, Discussion, If, Include)):
# Pattern executors emit their own replay step events.
return self._execute_single_step_uncached(
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth,
)
span = _replay_step_begin(self, step)
if span is None:
return self._execute_leaf_step(
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth,
)
try:
result = self._execute_leaf_step(
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth, _replay=span,
)
except BaseException:
_replay_step_end(span, "failed")
raise
is_dict = isinstance(result, dict)
status = span.get("status") or (
"skipped" if is_dict and result.get("skipped")
else "failed" if is_dict and result.get("error") else "completed"
)
# Variables this step set, minus its output variable, for replay's step_end.
# A cache hit leaves all_variables untouched, so it is its own baseline.
before = span.pop("before", all_variables)
step_vars = {}
if is_dict:
step_vars = {
k: v for k, v in (result.get("variables") or {}).items()
if k not in before or _differs(before[k], v)
}
step_vars.pop(getattr(step, "output_variable", None) or f"{result.get('step')}_output", None)
_replay_step_end(span, status, result.get("output") if is_dict else None, step_vars)
return result
def _execute_leaf_step(
self,
step: Any,
previous_output: Optional[str],
input: str,
all_variables: Dict[str, Any],
model: str,
verbose: bool,
index: int = 0,
stream: bool = True,
depth: int = 0,
_replay: Optional[dict] = None,
) -> Dict[str, Any]:
"""Leaf-step body of :meth:`_execute_single_step_internal` (gate + cache)."""
if depth > MAX_NESTING_DEPTH:
raise ValueError(
f"Maximum nesting depth ({MAX_NESTING_DEPTH}) exceeded. "
@@ -3205,7 +3014,6 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
}
cache = getattr(self, "_step_cache", None)
if cache is None:
_snapshot_scope(_replay, all_variables)
return self._execute_single_step_uncached(
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth,
@@ -3228,11 +3036,8 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if hasattr(step, "status"):
step.status = "completed"
self.step_statuses[step.name] = "completed"
if _replay is not None:
_replay["status"] = "cached"
# Copy: a caller mutating a returned result must not edit the cache.
return dict(hit)
_snapshot_scope(_replay, all_variables)
result = self._execute_single_step_uncached(
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth,
@@ -3605,18 +3410,19 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
# Handle output_file - save output to file
if hasattr(step, 'output_file') and step.output_file and output and not step_error:
try:
from ..utils.output_file import write_output_file
output_path = step.output_file
for key, value in all_variables.items():
output_path = output_path.replace(f"{{{{{key}}}}}", str(value))
if write_output_file(output_path, str(output)) and verbose:
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
with open(output_path, "w") as f:
f.write(str(output))
if verbose:
print(f"📁 Saved output to: {output_path}")
except Exception as e:
logger.error(f"Failed to save output to file: {e}")
return output, step_error
@_replay_pattern("route")
def _execute_route(
self,
route_step: Route,
@@ -3647,9 +3453,6 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if matched_route is None:
matched_route = route_step.default or []
_replay_note_route("default")
else:
_replay_note_route(key)
if verbose:
route_name = next((k for k in route_step.routes if route_step.routes[k] == matched_route), "default")
@@ -3901,7 +3704,6 @@ CONCISE SUMMARY:"""
winners[key] = idx
all_variables[key] = value
@_replay_pattern("parallel")
def _execute_parallel(
self,
parallel_step: Parallel,
@@ -4154,7 +3956,6 @@ CONCISE SUMMARY:"""
return {"steps": results, "output": combined_output, "variables": all_variables, "stop": parallel_stopped}
@_replay_pattern("loop")
def _execute_loop(
self,
loop_step: Loop,
@@ -4541,7 +4342,6 @@ CONCISE SUMMARY:"""
# 4. Fallback: wrap as single item
return [text]
@_replay_pattern("discussion")
def _execute_discussion(
self, discussion, previous_output, input, all_variables, model, verbose, stream=True, depth=0
):
@@ -4625,7 +4425,6 @@ CONCISE SUMMARY:"""
"stop": discussion_stopped,
}
@_replay_pattern("repeat")
def _execute_repeat(
self,
repeat_step: Repeat,
@@ -4700,7 +4499,6 @@ CONCISE SUMMARY:"""
all_variables["repeat_iterations"] = iteration + 1 if repeat_step.max_iterations > 0 else 0
return {"steps": results, "output": output, "variables": all_variables, "stop": repeat_stopped}
@_replay_pattern("if")
def _execute_if(
self,
if_step: If,
@@ -4732,7 +4530,6 @@ CONCISE SUMMARY:"""
# Evaluate the condition
condition_result = self._evaluate_condition(if_step.condition, all_variables, previous_output)
_replay_note_route("true" if condition_result else "false")
if verbose:
branch = "then" if condition_result else "else"
@@ -4786,7 +4583,6 @@ CONCISE SUMMARY:"""
from praisonaiagents.conditions.evaluator import evaluate_condition
return evaluate_condition(condition, variables, previous_output)
@_replay_pattern("include")
def _execute_include(
self,
include_step: Include,
@@ -1077,15 +1077,11 @@ class YAMLWorkflowParser:
# Build routes dictionary with resolved agents
routes = {}
for route_key, agent_ids in route_config.items():
if not isinstance(agent_ids, list):
agent_ids = [agent_ids]
try:
routes[route_key] = [self._agents[aid] for aid in agent_ids]
except KeyError as exc:
raise ValueError(
f"Agent '{exc.args[0]}' in route '{route_key}' "
"not defined in agents section"
) from exc
if isinstance(agent_ids, list):
routes[route_key] = [self._agents[aid] for aid in agent_ids if aid in self._agents]
else:
if agent_ids in self._agents:
routes[route_key] = [self._agents[agent_ids]]
return route(routes)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.19"
version = "1.7.16"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -1,113 +0,0 @@
"""Real agentic test for PraisonAI Replay events (AGENTS.md section 9.4).
Runs a real two-agent AgentTeam and a real hand-off against gpt-4o-mini while
recording with a ContextListSink, then checks the trace a replay needs:
workflow, task, agent and LLM events (on both the OpenAI-client path and the
LiteLLM path), a real hand-off, one run id and parent links that resolve.
About five small model calls. Opt in with:
RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 OPENAI_API_KEY=... \
pytest tests/integration/test_replay_events_real.py -s
"""
import os
import pytest
pytestmark = [
pytest.mark.network,
pytest.mark.skipif(
os.getenv("RUN_REAL_KEY_TESTS") != "1"
or not os.getenv("OPENAI_API_KEY")
or os.getenv("OPENAI_API_KEY") == "test-key",
reason="Real model calls: set RUN_REAL_KEY_TESTS=1, PRAISONAI_ALLOW_NETWORK=1 and a real OPENAI_API_KEY",
),
]
def _types(events):
return [e.event_type.value for e in events]
def test_team_and_handoff_record_a_replayable_trace():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
from praisonaiagents.trace.context_events import (
ContextListSink, ContextTraceEmitter, trace_context,
)
sink = ContextListSink()
emitter = ContextTraceEmitter(sink=sink, session_id="replay-real", enabled=True)
# Researcher uses the OpenAI-client path, Writer the LiteLLM path.
researcher = Agent(name="Researcher", instructions="Answer in one short sentence.",
llm="gpt-4o-mini")
writer = Agent(name="Writer", instructions="Rewrite the given text as a headline of at most eight words.",
llm="openai/gpt-4o-mini")
team = AgentTeam(
agents=[researcher, writer],
tasks=[
Task(name="research", description="State one fact about the Moon.",
expected_output="One sentence", agent=researcher),
Task(name="headline", description="Turn the fact into a headline.",
expected_output="A headline", agent=writer),
],
output="silent",
)
billing = Agent(name="Billing", instructions="Reply exactly: Refund logged.", llm="gpt-4o-mini")
triage = Agent(
name="Triage",
instructions=("You never answer yourself. For any request, immediately call the "
"transfer_to_billing tool, then repeat its answer."),
llm="gpt-4o-mini",
handoffs=[billing],
)
with trace_context(emitter):
team_output = team.start()
handoff_output = triage.start("Please refund order 7.")
events = sink.get_events()
print("\n--- team output ---\n", team_output)
print("--- hand-off output ---\n", handoff_output)
print("--- trace ---")
for e in events:
print(f"{e.sequence_num:>3} parent={e.parent_seq!s:>4} {e.event_type.value:<18} "
f"{(e.agent_name or ''):<11} {e.node_id or ''}")
assert team_output and handoff_output
types = _types(events)
for needed in ("workflow_start", "workflow_end", "task_start", "task_end",
"agent_start", "agent_end", "llm_request", "llm_response", "agent_handoff"):
assert needed in types, needed
start = next(e for e in events if e.event_type.value == "workflow_start")
assert start.data["kind"] == "team" and start.data["graph"]["schema"] == "praisonai.graph/1"
graph_ids = {n["id"] for n in start.data["graph"]["nodes"]}
task_ends = [e for e in events if e.event_type.value == "task_end"]
assert [e.data["status"] for e in task_ends] == ["completed", "completed"]
assert {e.node_id for e in task_ends} == {"task:research", "task:headline"} <= graph_ids
team_events = [e for e in events if e.run_id == team.run_id]
assert team_events and team_events[0] is start
# LLM activity on both paths, attributed to the right agent.
responses = [e for e in events if e.event_type.value == "llm_response"]
by_agent = {e.agent_name for e in responses}
assert {"Researcher", "Writer", "Triage", "Billing"} <= by_agent
writer_calls = [e for e in responses if e.agent_name == "Writer"]
assert "gpt-4o-mini" in writer_calls[0].data.get("model") # recorded by the LLM class
assert writer_calls[0].prompt_tokens > 0 and writer_calls[0].completion_tokens > 0
assert len([e for e in events if e.event_type.value == "llm_request" and e.agent_name == "Writer"]) == len(writer_calls)
handoffs = [e for e in events if e.event_type.value == "agent_handoff"]
assert any(h.data["from_agent"] == "Triage" and h.data["to_agent"] == "Billing"
and h.data["kind"] == "handoff" for h in handoffs)
# Every parent link resolves to an earlier event; sequence numbers are unique.
seqs = [e.sequence_num for e in events]
assert seqs == list(range(len(events)))
for e in events:
assert e.parent_seq is None or e.parent_seq < e.sequence_num
@@ -1274,13 +1274,12 @@ class TestTaskTools:
class TestMigratedFeatures:
"""Test features migrated from process='workflow'."""
def test_step_output_file(self, monkeypatch):
def test_step_output_file(self):
"""Test Task with output_file parameter."""
import tempfile
import os
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setenv("PRAISONAI_PROJECT_ROOT", tmpdir)
output_path = os.path.join(tmpdir, "output.txt")
def generate_content(ctx: WorkflowContext) -> StepResult:
@@ -1,130 +0,0 @@
"""A configured completion promise must be the only success signal (#6027)."""
import asyncio
import pytest
from praisonaiagents import Agent
from praisonaiagents.model_harness import ScriptedModel
NOT_DONE = (
"I listed the files and started on the parser, but work remains: the tokenizer "
"is still failing two tests and the CLI flag is not wired yet. Continuing next turn."
)
DONE_REPLY = "Fixed the tokenizer and wired the flag. <promise>DONE</promise>"
PROMPT = "Refactor auth module" # stage=autonomous
DIRECT_PROMPT = "Implement feature X" # stage=direct
@pytest.fixture(autouse=True)
def _offline(monkeypatch, tmp_path):
for key in ("OPENAI_BASE_URL", "OPENAI_API_BASE", "ANTHROPIC_BASE_URL"):
monkeypatch.setenv(key, "http://127.0.0.1:9")
monkeypatch.setenv("HOME", str(tmp_path))
def list_files(path: str) -> str:
"""List files in a directory."""
return "parser.py tokenizer.py cli.py"
def _script(follow_ups=1):
return (
[ScriptedModel.tool_call("list_files", {"path": "."})]
+ [NOT_DONE] * follow_ups
+ [DONE_REPLY] * 3
)
def _make(model, **autonomy):
return Agent(
name="worker", instructions="Do the task.", llm=model, tools=[list_files],
output="silent", autonomy={"level": "full_auto", **autonomy},
)
def _assert_promise(result):
assert result.success is True
assert result.completion_reason == "promise"
assert "<promise>DONE</promise>" in result.output
assert result.iterations >= 2
def test_sync_tool_completion_does_not_preempt_promise():
r = _make(ScriptedModel(_script())).run_autonomous(
PROMPT, completion_promise="DONE", max_iterations=5)
_assert_promise(r)
def test_async_waits_for_promise():
r = asyncio.run(_make(ScriptedModel(_script(2))).run_autonomous_async(
PROMPT, completion_promise="DONE", max_iterations=5))
_assert_promise(r)
def test_sync_direct_stage_waits_for_promise():
r = _make(ScriptedModel([NOT_DONE, DONE_REPLY])).run_autonomous(
DIRECT_PROMPT, completion_promise="DONE", max_iterations=5)
_assert_promise(r)
def test_async_direct_stage_waits_for_promise():
r = asyncio.run(_make(ScriptedModel([NOT_DONE, DONE_REPLY])).run_autonomous_async(
DIRECT_PROMPT, completion_promise="DONE", max_iterations=5))
_assert_promise(r)
def test_promise_never_emitted_ends_unsuccessfully():
r = _make(ScriptedModel([NOT_DONE] * 5)).run_autonomous(
PROMPT, completion_promise="DONE", max_iterations=3)
assert r.success is False
assert r.completion_reason == "max_iterations"
def test_iterative_mode_start_honours_promise():
agent = Agent(
name="loop_agent", instructions="Complete the task.", llm=ScriptedModel(_script()),
tools=[list_files], output="silent",
autonomy={"mode": "iterative", "max_iterations": 10,
"completion_promise": "DONE", "observe": False},
)
_assert_promise(agent.start(PROMPT))
def test_without_promise_tool_completion_unchanged():
r = _make(ScriptedModel(_script())).run_autonomous(PROMPT, max_iterations=5)
assert r.success is True
assert r.completion_reason == "tool_completion"
assert r.iterations == 1
def test_async_tool_path_counts_tools():
r = asyncio.run(_make(ScriptedModel(_script(2))).run_autonomous_async(
PROMPT, max_iterations=5))
assert r.success is True
assert r.completion_reason == "tool_completion"
assert r.iterations == 1
def test_promise_tag_is_sent_to_model():
model = ScriptedModel([DONE_REPLY])
_make(model).run_autonomous(PROMPT, completion_promise="DONE", max_iterations=2)
assert "<promise>DONE</promise>" in model.requests[0].last_user_message
def test_async_blocked_tool_does_not_count_as_tool_completion():
from praisonaiagents.hooks import HookEvent, HookRegistry, HookResult
registry = HookRegistry()
@registry.on(HookEvent.BEFORE_TOOL)
def deny(data):
return HookResult.deny("blocked")
agent = Agent(
name="worker", instructions="Do the task.", llm=ScriptedModel(_script(2)),
tools=[list_files], output="silent", hooks=registry,
autonomy={"level": "full_auto"},
)
r = asyncio.run(agent.run_autonomous_async(PROMPT, max_iterations=3))
assert r.completion_reason != "tool_completion"
@@ -154,14 +154,9 @@ class TestPromptReinjection:
completion_promise="DONE"
)
# All prompts should be the original (plus the promise instruction),
# not "Continue with the task"
expected = (
"Build a REST API\n\nWhen the task is fully complete, include "
"<promise>DONE</promise> in your response."
)
# All prompts should be the original, not "Continue with the task"
for prompt in prompts_received:
assert prompt == expected, f"Expected original prompt, got: {prompt}"
assert prompt == "Build a REST API", f"Expected original prompt, got: {prompt}"
def test_prompt_not_continue_with_task(self):
"""Prompt should NOT be 'Continue with the task' after first iteration."""
@@ -1,169 +0,0 @@
"""Regression tests for #6072: streaming bridge, async tool pool, breaker neutrality."""
import asyncio
import threading
import time
import pytest
from praisonaiagents import Agent
def _agent(**kw):
return Agent(name="t", instructions="x", llm="gpt-4o-mini", **kw)
class _Backend:
def __init__(self, fail_first=False):
self.produced = 0
self.calls = 0
self.fail_first = fail_first
async def stream(self, prompt, **kw):
self.calls += 1
if self.fail_first and self.calls == 1:
yield "a"
raise RuntimeError("boom")
for i in range(100000):
self.produced += 1
yield f"c{i}"
await asyncio.sleep(0)
def test_stream_close_in_running_loop_stops_producer():
a = _agent()
a.backend = _Backend()
async def main():
g = a._delegate_streaming_to_backend("p")
next(g)
g.close()
after_close = a.backend.produced
time.sleep(0.3)
return a.backend.produced - after_close
produced = asyncio.run(main())
assert produced == 0
def test_backend_runtime_error_does_not_rerun_stream():
a = _agent()
a.backend = _Backend(fail_first=True)
async def main():
with pytest.raises(RuntimeError, match="boom"):
for _ in a._delegate_streaming_to_backend("p"):
pass
asyncio.run(main())
assert a.backend.calls == 1
def test_async_sync_tool_with_timeout_uses_agent_pool():
seen = {}
def probe() -> str:
seen["thread"] = threading.current_thread().name
return "ok"
a = _agent(tools=[probe])
a._tool_timeout = 5
assert asyncio.run(a.execute_tool_async("probe", {})) == "ok"
assert seen["thread"].startswith("tool-t")
def test_async_timed_out_tool_does_not_hold_default_executor():
def hang() -> str:
time.sleep(2)
return "ok"
a = _agent(tools=[hang])
a._tool_timeout = 0.1
async def main():
tasks = [asyncio.create_task(a.execute_tool_async("hang", {})) for _ in range(40)]
await asyncio.gather(*tasks)
start = time.monotonic()
await asyncio.to_thread(lambda: 1)
return time.monotonic() - start
assert asyncio.run(main()) < 1.0
def test_caller_errors_do_not_open_breaker():
def read_file(path: str) -> str:
raise FileNotFoundError(path)
a = _agent(tools=[read_file])
for i in range(7):
result = a._execute_tool_with_circuit_breaker_impl("read_file", {"path": f"/nope{i}"})
assert not result.get("circuit_open")
def test_open_breaker_remediation_uses_live_config():
a = _agent()
breaker = a._get_tool_circuit_breaker("x")
breaker.config.recovery_timeout = 7
assert "(7s)" in a._circuit_breaker_open_result("x")["remediation"]
def test_stream_close_runs_backend_cleanup():
cleaned = threading.Event()
class _CleanupBackend:
async def stream(self, prompt, **kw):
try:
while True:
yield "c"
await asyncio.sleep(0)
finally:
cleaned.set()
a = _agent()
a.backend = _CleanupBackend()
g = a._delegate_streaming_to_backend("p")
next(g)
g.close()
assert cleaned.wait(2)
def test_parallel_healthy_timed_tools_do_not_queue_into_timeout():
def work(i: int) -> str:
time.sleep(0.6)
return "ok"
a = _agent(tools=[work])
a._tool_timeout = 1
async def main():
return await asyncio.gather(*(a.execute_tool_async("work", {"i": i}) for i in range(5)))
# Loop-guard may append advisory notes; what matters is no timeout.
assert all(isinstance(r, str) and r.startswith("ok") for r in asyncio.run(main()))
def test_hung_spill_threads_are_capped_across_bursts():
from praisonaiagents.agent import tool_execution as te
release = threading.Event()
a = _agent()
started = 0
try:
for _ in range(5):
# A burst of concurrent calls that all hang past their timeout;
# each timed-out burst retires the stuck pool as the async path does.
for _ in range(20):
future, executor, _ = a._submit_to_tool_pool(lambda: release.wait(10), spill=True)
started += future is not None
a._retire_tool_executor(a._tool_executor)
assert a._tool_spill_inflight <= te._MAX_SPILL_TOOL_THREADS
cap = (
te._MAX_SPILL_TOOL_THREADS
+ (te._MAX_ORPHANED_TOOL_EXECUTORS + 1) * te._TOOL_POOL_MAX_WORKERS
)
assert started <= cap
finally:
release.set()
deadline = time.monotonic() + 5
while a._tool_spill_inflight and time.monotonic() < deadline:
time.sleep(0.01)
assert a._tool_spill_inflight == 0
@@ -1,199 +0,0 @@
"""Budget and cost accounting on the custom-LLM (``provider/model``) path (#6029).
That path hands the whole turn, tool loop included, to ``LLM.get_response*``.
Every call it makes must reach the Agent's cost/token counters so
``ExecutionConfig.max_budget`` and the autonomy spend caps can fire.
``litellm.completion`` is stubbed; nothing leaves the machine.
"""
import asyncio
import pytest
pytest.importorskip("litellm")
from praisonaiagents import Agent # noqa: E402
from praisonaiagents.config.feature_configs import ExecutionConfig # noqa: E402
from praisonaiagents.errors import BudgetExceededError # noqa: E402
from praisonaiagents.utils.cost_utils import calculate_llm_cost # noqa: E402
MODEL = "anthropic/claude-3-5-sonnet-20240620"
USAGE = {"prompt_tokens": 1500, "completion_tokens": 1500, "total_tokens": 3000}
def _litellm():
"""The litellm module LLM will import now.
Other tests evict ``litellm`` from sys.modules, so a module-level binding
can go stale and stubbing it would let the real client run.
"""
import litellm
return litellm
def _text(model):
return _litellm().ModelResponse(model=model, usage=USAGE, choices=[{
"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "ok"}}])
def _tool_call(model, n):
return _litellm().ModelResponse(model=model, usage=USAGE, choices=[{
"index": 0, "finish_reason": "tool_calls",
"message": {"role": "assistant", "content": None, "tool_calls": [{
"id": f"call_{n}", "type": "function",
"function": {"name": "step", "arguments": "{}"}}]}}])
@pytest.fixture
def calls(monkeypatch):
"""Stub litellm; answer with a tool call while ``tool_rounds`` remain."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
# Agent caches its env-derived default model per process; keep the fake
# key's Anthropic default from leaking into later tests.
monkeypatch.setattr(Agent, "_default_model", None)
monkeypatch.setattr(Agent, "_default_model_checked", False)
state = {"n": 0, "tool_rounds": 0}
def fake(*a, **k):
state["n"] += 1
if k.get("tools") and state["tool_rounds"] > 0:
state["tool_rounds"] -= 1
return _tool_call(k.get("model"), state["n"])
return _text(k.get("model"))
async def afake(*a, **k):
return fake(*a, **k)
litellm = _litellm()
monkeypatch.setattr(litellm, "completion", fake)
monkeypatch.setattr(litellm, "acompletion", afake)
return state
def _agent(**kw):
return Agent(name="p", instructions="x", llm=MODEL, output="silent", **kw)
def step() -> str:
"""One step of work."""
return "done"
def test_sync_chat_counts_cost_and_tokens(calls):
agent = _agent()
assert agent.chat("hi") == "ok"
summary = agent.cost_summary
assert summary["llm_calls"] == 1
assert summary["tokens_in"] == 1500 and summary["tokens_out"] == 1500
assert agent.total_cost > 0
def test_sync_budget_raises_between_turns(calls):
agent = _agent(execution=ExecutionConfig(max_budget=0.05))
with pytest.raises(BudgetExceededError):
for i in range(5):
agent.chat(f"hello {i}")
assert calls["n"] < 5
assert agent.total_cost >= 0.05
def test_async_budget_raises_between_turns(calls):
agent = _agent(execution=ExecutionConfig(max_budget=0.05))
async def run():
for i in range(5):
await agent.achat(f"hello {i}")
with pytest.raises(BudgetExceededError):
asyncio.run(run())
assert calls["n"] < 5
assert agent.total_cost >= 0.05
def test_budget_trips_mid_tool_loop(calls):
calls["tool_rounds"] = 10 # the model would keep calling tools for 11 calls
agent = _agent(execution=ExecutionConfig(max_budget=1.0))
agent.chat("price one call")
one_call = agent.total_cost
calls["n"] = 0
# A cap between one and two calls must stop the loop at the second call.
agent = _agent(tools=[step], execution=ExecutionConfig(max_budget=one_call * 1.5))
with pytest.raises(BudgetExceededError):
agent.chat("go")
assert calls["n"] == 2
def test_warn_mode_does_not_raise(calls):
agent = _agent(execution=ExecutionConfig(max_budget=0.001, on_budget_exceeded="warn"))
assert agent.chat("hi") == "ok"
assert agent.total_cost > 0.001
def test_autonomy_budget_exhausted_on_provider_path(calls):
agent = _agent(autonomy={"max_budget_usd": 0.001})
result = agent.run_autonomous("go", max_iterations=3)
assert result.completion_reason == "budget_exhausted"
assert calls["n"] == 1
def test_sink_ignores_other_llm_instances(calls):
"""A call made by a different LLM object is not billed to this agent."""
from praisonaiagents.llm.llm import LLM
agent = _agent()
other = LLM(model=MODEL)
with agent._llm_usage_sink():
other._track_token_usage(_text(MODEL), MODEL)
assert agent.cost_summary["llm_calls"] == 0
class _Unavailable(Exception):
status_code = 503
def test_fallback_model_spend_is_counted(calls, monkeypatch):
"""A turn answered by a ``fallback_models`` entry is still billed to the agent."""
backup = "anthropic/claude-3-haiku-20240307"
def fake(*a, **k):
calls["n"] += 1
if k.get("model") != backup:
raise _Unavailable("503 overloaded")
return _text(backup)
async def afake(*a, **k):
return fake(*a, **k)
monkeypatch.setattr(_litellm(), "completion", fake)
monkeypatch.setattr(_litellm(), "acompletion", afake)
for run in (lambda a: a.chat("hi"), lambda a: asyncio.run(a.achat("hi"))):
agent = _agent(retry=False) # fail over at once, no backoff sleeps
agent.fallback_models = [backup]
assert run(agent) == "ok"
assert agent.cost_summary["llm_calls"] == 1
# Priced as the model that answered, not the (pricier) primary.
assert agent.total_cost == pytest.approx(calculate_llm_cost(1500, 1500, backup))
assert agent.total_cost != pytest.approx(calculate_llm_cost(1500, 1500, MODEL))
def test_retry_after_exhaustion_sends_no_request(calls):
agent = _agent(execution=ExecutionConfig(max_budget=0.001))
with pytest.raises(BudgetExceededError):
agent.chat("first")
sent = calls["n"]
with pytest.raises(BudgetExceededError):
agent.chat("retry")
with pytest.raises(BudgetExceededError):
asyncio.run(agent.achat("retry async"))
assert calls["n"] == sent
def test_autonomy_budget_error_keeps_budget_exhausted(calls):
"""A max_budget stop inside the turn still reports the autonomy cap result."""
agent = _agent(execution=ExecutionConfig(max_budget=0.001),
autonomy={"max_budget_usd": 0.001})
result = agent.run_autonomous("go", max_iterations=3)
assert result.completion_reason == "budget_exhausted"
result = asyncio.run(_agent(execution=ExecutionConfig(max_budget=0.001),
autonomy={"max_budget_usd": 0.001}).run_autonomous_async("go", max_iterations=3))
assert result.completion_reason == "budget_exhausted"
@@ -1,27 +0,0 @@
from praisonaiagents.agent.context_agent import ContextAgent
NON_ASCII = "日本語 café ☕"
def test_context_engineering_results_written_as_readable_utf8(tmp_path, monkeypatch):
"""Every saved Context Engineering result file keeps non-ASCII content as readable UTF-8."""
# ContextAgent defaults output to .praisonai/prp under the cwd, so run inside tmp.
monkeypatch.chdir(tmp_path)
agent = ContextAgent(llm="gpt-4o-mini", auto_analyze=False)
agent.output_dir = str(tmp_path)
agent.codebase_analysis = {"summary": NON_ASCII}
agent.pattern_library = {"summary": NON_ASCII}
agent.validation_framework = {"summary": NON_ASCII}
agent.agent_interactions = [{"response": NON_ASCII}]
agent._save_context_engineering_results()
for filename in (
"comprehensive_analysis.json",
"pattern_library.json",
"validation_framework.json",
"all_agent_interactions.json",
):
saved = (tmp_path / filename).read_text(encoding="utf-8")
assert NON_ASCII in saved, filename
assert "\\u" not in saved, filename
@@ -388,8 +388,7 @@ def test_cancellation_during_final_save_finishes_record_and_cleanup(recorded_age
assert db.starts[0]["run_id"] == db.ends[0]["run_id"]
assert db.ends[0]["status"] == "completed"
assert agent._active_turn_token is None
# The answer came back; only the final save was cancelled.
emitter.agent_end.assert_called_once_with(agent.name, {"status": "completed"})
emitter.agent_end.assert_called_once_with(agent.name)
token.close.assert_called_once()
@@ -1,162 +0,0 @@
"""Regression tests for #5795: tool/MCP calls must not be timed out by queue wait."""
import asyncio
import concurrent.futures as cf
import queue
import threading
import time
from contextlib import asynccontextmanager
from types import SimpleNamespace
from praisonaiagents import Agent
from praisonaiagents.hooks import HookEvent, HookRegistry
def _allow_all_hooks():
"""Own registry (no leaked global hooks) with a sync BEFORE_TOOL hook,
so parallel tool calls also run the hook concurrently."""
registry = HookRegistry()
registry.register_function(HookEvent.BEFORE_TOOL, lambda data: None)
return registry
def test_parallel_sync_tools_not_timed_out_by_queue_wait():
def slow(n: int) -> str:
"""slow tool"""
time.sleep(0.5)
return f"ok{n}"
agent = Agent(name="t", instructions="x", tools=[slow], llm="gpt-4o-mini", hooks=_allow_all_hooks())
agent._tool_timeout = 0.8
with cf.ThreadPoolExecutor(5) as ex:
results = list(ex.map(lambda i: agent.execute_tool("slow", {"n": i}), range(5)))
for i, result in enumerate(results):
assert isinstance(result, str) and result.startswith(f"ok{i}"), result
def test_parallel_async_sync_tools_not_timed_out_by_queue_wait():
def slow(n: int) -> str:
"""slow tool"""
time.sleep(0.5)
return f"ok{n}"
agent = Agent(name="a", instructions="x", tools=[slow], llm="gpt-4o-mini", hooks=_allow_all_hooks())
agent._tool_timeout = 0.8
async def main():
# Twice the pool size: the second half queues for a worker first.
return await asyncio.gather(
*(agent.execute_tool_async("slow", {"n": i}) for i in range(10))
)
for i, result in enumerate(asyncio.run(main())):
assert isinstance(result, str) and result.startswith(f"ok{i}"), result
def test_hung_async_sync_tools_do_not_starve_default_executor():
release = threading.Event()
tools = []
for i in range(10):
def hang(x: int = 0) -> str:
"""hang"""
release.wait(10)
return "x"
hang.__name__ = f"hang{i}"
tools.append(hang)
agent = Agent(name="u", instructions="x", tools=tools, llm="gpt-4o-mini", hooks=HookRegistry())
agent._tool_timeout = 0.2
async def main():
for i in range(10):
result = await asyncio.wait_for(agent.execute_tool_async(f"hang{i}", {"x": 1}), 5)
# Times out, or is refused fast once hung calls hit the pool cap.
assert isinstance(result, dict) and (result.get("timeout") or result.get("tool_pool_exhausted")), result
# The loop's default executor is still free for other to_thread users.
assert await asyncio.wait_for(asyncio.to_thread(lambda: 42), 2) == 42
try:
asyncio.run(main())
finally:
release.set()
def test_transport_wrapper_cancels_call_on_timeout():
from praisonaiagents.mcp.mcp_sse import SSEMCPTool
loop = asyncio.new_event_loop()
threading.Thread(target=loop.run_forever, daemon=True).start()
finished = threading.Event()
class _Session:
async def call_tool(self, name, kwargs):
await asyncio.sleep(1.0)
finished.set()
try:
tool = SSEMCPTool("t", "d", _Session(), {}, timeout=0.1)
tool._loop = loop
result = tool()
assert isinstance(result, dict) and result.get("timeout"), result
time.sleep(1.3)
assert not finished.is_set()
finally:
loop.call_soon_threadsafe(loop.stop)
def test_stdio_runner_runs_requests_concurrently_and_cancels_on_shutdown(monkeypatch):
import praisonaiagents.mcp.mcp as mcp_mod
class _Session:
async def initialize(self):
pass
async def list_tools(self):
return SimpleNamespace(tools=[])
async def call_tool(self, name, arguments):
await asyncio.sleep(10 if name == "slow" else 0)
return name
@asynccontextmanager
async def _stdio_client(params):
yield None, None
@asynccontextmanager
async def _client_session(read, write):
yield _Session()
async def _no_discovery(self, session):
pass
monkeypatch.setattr(mcp_mod, "stdio_client", _stdio_client)
monkeypatch.setattr(mcp_mod, "ClientSession", _client_session)
monkeypatch.setattr(mcp_mod.MCPToolRunner, "_discover_resources_and_prompts", _no_discovery)
runner = mcp_mod.MCPToolRunner(None, timeout=30)
assert runner.initialized.wait(5) and runner._init_error is None
slow_q, fast_q = queue.Queue(), queue.Queue()
runner.queue.put((slow_q, "tool", "slow", {}, threading.Event()))
runner.queue.put((fast_q, "tool", "fast", {}, threading.Event()))
# The fast call is not serialized behind the in-flight slow one.
assert fast_q.get(timeout=2) == (True, "fast")
runner.queue.put(None)
ok, message = slow_q.get(timeout=2)
assert not ok and "shut down" in message
runner.join(2)
assert not runner.is_alive()
def test_agent_deepcopy_after_async_tool_use():
import copy
def echo(x: int) -> int:
"""echo"""
return x
agent = Agent(name="d", instructions="x", tools=[echo], llm="gpt-4o-mini")
assert asyncio.run(agent.execute_tool_async("echo", {"x": 1})) == 1
clone = copy.deepcopy(agent)
assert "_tool_executor" not in clone.__dict__
assert asyncio.run(clone.execute_tool_async("echo", {"x": 2})) == 2
@@ -1,93 +0,0 @@
"""Loop guard per-turn time limit is configurable via ExecutionConfig (#6026)."""
import asyncio
import pytest
import praisonaiagents.escalation.loop_guard as lg
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
from praisonaiagents.escalation.loop_guard import GuardAction, LoopGuardConfig
from praisonaiagents.model_harness import ScriptedModel
@pytest.fixture
def clock(monkeypatch):
state = {"t": 1_000_000.0}
class FakeTime:
@staticmethod
def time():
return state["t"]
monkeypatch.setattr(lg, "time", FakeTime)
return state
def _agent(clock, tool_seconds, execution=None, calls=None):
calls = [] if calls is None else calls
def run_tests(suite: str) -> str:
"""Run a (slow) test suite."""
calls.append(suite)
clock["t"] += tool_seconds
return f"{suite}: 412 passed"
model = ScriptedModel([
ScriptedModel.tool_call("run_tests", {"suite": "unit"}),
ScriptedModel.tool_call("run_tests", {"suite": "integration"}),
"All suites passed.",
])
kwargs = {"execution": execution} if execution is not None else {}
return Agent(name="coder", instructions="Run the tests.", llm=model,
tools=[run_tests], output="silent", **kwargs)
@pytest.mark.parametrize("use_async", [False, True])
def test_default_turn_with_130s_tool_completes(clock, use_async):
agent = _agent(clock, 130.0)
if use_async:
out = asyncio.run(agent.achat("Run both suites"))
else:
out = agent.chat("Run both suites")
assert "All suites passed" in str(out)
@pytest.mark.parametrize("use_async", [False, True])
def test_configured_limit_halts(clock, use_async):
calls = []
agent = _agent(clock, 130.0, ExecutionConfig(max_execution_time=60), calls)
if use_async:
asyncio.run(agent.achat("Run both suites"))
else:
with pytest.raises(Exception, match="Turn time limit exceeded"):
agent.chat("Run both suites")
# The second tool call is refused by the loop guard on both paths.
assert calls == ["unit"]
@pytest.mark.parametrize("use_async", [False, True])
def test_zero_disables_time_halt(clock, use_async):
agent = _agent(clock, 10_000.0, ExecutionConfig(max_execution_time=0))
if use_async:
out = asyncio.run(agent.achat("Run both suites"))
else:
out = agent.chat("Run both suites")
assert "All suites passed" in str(out)
def test_ensure_loop_guard_reads_max_execution_time():
agent = Agent(name="a", instructions="x", llm=ScriptedModel(["ok"]),
output="silent", execution=ExecutionConfig(max_execution_time=900))
assert agent._ensure_loop_guard().config.max_time_per_turn == 900.0
def test_real_loop_still_halts_without_time_limit():
guard = lg.LoopGuard(LoopGuardConfig(max_time_per_turn=float("inf")))
actions = []
for _ in range(20):
decision = guard.check("read_file", {"path": "a.txt"})
actions.append(decision.action)
if decision.action == GuardAction.HALT:
break
guard.record("read_file", {"path": "a.txt"}, success=True, result="same")
assert actions[-1] == GuardAction.HALT
@@ -1,126 +0,0 @@
"""``ExecutionConfig.max_execution_time`` and ``run_autonomous(timeout_seconds=)`` (#6029).
Both caps become a deadline on the turn's cancel token, so the LLM tool loop
stops at its next boundary instead of running to completion.
``litellm.completion`` is stubbed; nothing leaves the machine.
"""
import asyncio
import time
import pytest
pytest.importorskip("litellm")
from praisonaiagents import Agent # noqa: E402
from praisonaiagents.config.feature_configs import ExecutionConfig # noqa: E402
from praisonaiagents.escalation.loop_guard import LoopGuardConfig # noqa: E402
MODEL = "anthropic/claude-3-5-sonnet-20240620"
USAGE = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
@pytest.fixture
def calls(monkeypatch):
"""Stub litellm so the model asks for ``slow_step`` on every call."""
# Import here, not at module level: other tests evict ``litellm`` from
# sys.modules, and LLM then imports a fresh module object at call time.
import litellm
from litellm import ModelResponse
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
# Agent caches its env-derived default model per process; keep the fake
# key's Anthropic default from leaking into later tests.
monkeypatch.setattr(Agent, "_default_model", None)
monkeypatch.setattr(Agent, "_default_model_checked", False)
state = {"n": 0}
def fake(*a, **k):
state["n"] += 1
if not k.get("tools"):
return ModelResponse(model=k.get("model"), usage=USAGE, choices=[{
"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "done"}}])
return ModelResponse(model=k.get("model"), usage=USAGE, choices=[{
"index": 0, "finish_reason": "tool_calls",
"message": {"role": "assistant", "content": None, "tool_calls": [{
"id": f"call_{state['n']}", "type": "function",
"function": {"name": "slow_step", "arguments": "{}"}}]}}])
async def afake(*a, **k):
return fake(*a, **k)
monkeypatch.setattr(litellm, "completion", fake)
monkeypatch.setattr(litellm, "acompletion", afake)
return state
def slow_step() -> str:
"""A slow step."""
time.sleep(0.4)
return "ok"
def _agent(**kw):
return Agent(name="t", instructions="x", llm=MODEL, tools=[slow_step],
output="silent", **kw)
def test_max_execution_time_stops_tool_loop(calls):
agent = _agent(execution=ExecutionConfig(max_execution_time=1, max_iter=50))
t0 = time.time()
out = agent.chat("go")
elapsed = time.time() - t0
assert elapsed < 3
assert "timeout" in (out or "")
assert agent.last_stop_reason == "timeout"
assert calls["n"] < 10
def test_max_execution_time_stops_tool_loop_async(calls):
agent = _agent(execution=ExecutionConfig(max_execution_time=1, max_iter=50))
t0 = time.time()
out = asyncio.run(agent.achat("go"))
assert time.time() - t0 < 3
assert "timeout" in (out or "")
assert agent.last_stop_reason == "timeout"
def test_unset_max_execution_time_creates_no_deadline():
agent = Agent(name="t", instructions="x", output="silent")
assert agent._turn_cancel_token(None, explicit=False) is None
def test_loop_guard_turn_limit_follows_max_execution_time():
agent = Agent(name="t", instructions="x", output="silent",
execution=ExecutionConfig(max_execution_time=600))
assert agent._ensure_loop_guard().config.max_time_per_turn == 600
default = Agent(name="d", instructions="x", output="silent")
assert default._ensure_loop_guard().config.max_time_per_turn == LoopGuardConfig.max_time_per_turn
def test_run_autonomous_timeout_bounds_the_running_turn(calls):
agent = _agent(autonomy=True, execution=ExecutionConfig(max_iter=50))
t0 = time.time()
result = agent.run_autonomous("go", max_iterations=1, timeout_seconds=1)
assert time.time() - t0 < 3
assert result.completion_reason == "timeout"
assert not result.success
def test_run_autonomous_async_timeout_bounds_the_running_turn(calls):
agent = _agent(autonomy=True, execution=ExecutionConfig(max_iter=50))
t0 = time.time()
result = asyncio.run(agent.run_autonomous_async("go", max_iterations=1, timeout_seconds=1))
assert time.time() - t0 < 3
assert result.completion_reason == "timeout"
def test_run_autonomous_reports_turn_max_execution_time(calls):
"""A turn cut by max_execution_time is a failed timeout, not a goal."""
agent = _agent(autonomy=True, execution=ExecutionConfig(max_execution_time=1, max_iter=50))
result = agent.run_autonomous("go", max_iterations=3)
assert result.completion_reason == "timeout"
assert not result.success
result = asyncio.run(agent.run_autonomous_async("go", max_iterations=3))
assert result.completion_reason == "timeout"
assert not result.success
@@ -1,35 +0,0 @@
"""Retained completed events are available to both wait entry points."""
import asyncio
from praisonaiagents import AgentTeam
def test_sync_default_returns_already_completed_events():
with AgentTeam(agents=[]) as team:
team.announce_completion("finished-a", "task-a", "result-a")
team.announce_completion("finished-b", "task-b", "result-b")
events = team.wait_for_completions(timeout=0.2)
assert [event.agent_id for event in events] == ["finished-a", "finished-b"]
def test_explicit_empty_ids_do_not_return_retained_events():
"""An explicit empty target set retains its previous empty result."""
with AgentTeam(agents=[]) as team:
team.announce_completion("finished-a", "task-a", "result-a")
assert team.wait_for_completions(timeout=0.2, agent_ids=[]) == []
def test_explicit_ids_filter_retained_events():
with AgentTeam(agents=[]) as team:
team.announce_completion("finished-a", "task-a", "result-a")
team.announce_completion("finished-b", "task-b", "result-b")
events = team.wait_for_completions(timeout=0.2, agent_ids=["finished-b"])
assert [event.agent_id for event in events] == ["finished-b"]
def test_async_default_returns_already_completed_events():
with AgentTeam(agents=[]) as team:
team.announce_completion("finished-a", "task-a", "result-a")
team.announce_completion("finished-b", "task-b", "result-b")
events = asyncio.run(team.await_for_completions(timeout=0.2))
assert [event.agent_id for event in events] == ["finished-a", "finished-b"]
@@ -1,49 +0,0 @@
"""Workflow dependents must honor failure policy after synchronous predecessors."""
import asyncio
import pytest
from praisonaiagents import Agent, AgentTeam, Task
class ScriptedAgent(Agent):
"""Exercise production task scheduling without provider requests."""
def __init__(self, name, reply):
super().__init__(name=name, instructions="test", llm="gpt-4o-mini")
self.reply = reply
self.calls = 0
def chat(self, prompt, **kwargs):
"""Record a real dispatch of this task's agent."""
self.calls += 1
return self.reply
async def achat(self, prompt, **kwargs):
"""Record asynchronous dispatch through the same fixture."""
return self.chat(prompt, **kwargs)
@pytest.mark.parametrize("entry", ["start", "astart"])
@pytest.mark.parametrize("upstream_async", [False, True])
@pytest.mark.parametrize("downstream_async", [False, True])
@pytest.mark.parametrize("policy", ["stop", "continue", "skip"])
def test_workflow_failed_predecessor_policy(entry, upstream_async, downstream_async, policy):
"""Default dependents do not execute; explicit degraded-mode ones still do."""
upstream = ScriptedAgent("upstream", None)
downstream = ScriptedAgent("downstream", "done")
first = Task(name="first", description="first", agent=upstream,
is_start=True, next_tasks=["second"], async_execution=upstream_async, max_retries=1)
flags = {"on_error": "continue"} if policy == "continue" else {}
if policy == "skip":
flags["skip_on_failure"] = True
second = Task(name="second", description="second", agent=downstream,
async_execution=downstream_async, max_retries=1, **flags)
team = AgentTeam(agents=[upstream, downstream], tasks=[first, second], process="workflow")
if entry == "astart":
asyncio.run(team.astart())
else:
team.start()
assert first.status == "failed"
assert upstream.calls == 1
assert downstream.calls == (0 if policy == "stop" else 1)
@@ -1,44 +0,0 @@
"""GitHub write tools must go through the approval system (issue #6023)."""
import pytest
from praisonaiagents.approval import get_approval_registry
from praisonaiagents.approval.registry import PERMISSION_PRESETS
GITHUB_WRITE_TOOLS = (
"github_create_branch",
"github_commit_and_push",
"github_create_pull_request",
)
@pytest.mark.parametrize("name", GITHUB_WRITE_TOOLS)
def test_registered_as_high_risk(name):
reg = get_approval_registry()
assert reg.is_required(name)
# high (not critical) so YAML/declarative approve lists still apply
assert reg.get_risk_level(name) == "high"
@pytest.mark.parametrize("name", GITHUB_WRITE_TOOLS)
def test_in_deny_presets(name):
for preset in ("default", "safe", "read_only"):
assert name in PERMISSION_PRESETS[preset]
assert name not in PERMISSION_PRESETS["full"]
def test_unattended_agent_denied_by_default(monkeypatch):
monkeypatch.delenv("PRAISONAI_TOOL_SAFETY", raising=False)
from praisonaiagents import Agent
agent = Agent(instructions="t", llm="gpt-4o-mini", approval=False)
for name in ("github_commit_and_push", "github_create_pull_request"):
result = agent._check_tool_approval_sync(name, {})
assert isinstance(result, dict) and result.get("permission_denied")
def test_explicit_full_preset_allows(monkeypatch):
monkeypatch.setenv("PRAISONAI_TOOL_SAFETY", "off")
from praisonaiagents import Agent
agent = Agent(instructions="t", llm="gpt-4o-mini", approval=False)
assert "github_commit_and_push" not in (agent._perm_deny or ())
@@ -1,176 +0,0 @@
"""Config options that were accepted but never read must either work or fail loud.
Covers GuardrailConfig.on_fail, ToolConfig.output_max_lines/output_direction and
rerank=True on the Agent's RAG pipeline (issue #6042). No network calls.
"""
import pytest
from praisonaiagents import Agent
from praisonaiagents.config import GuardrailConfig, ToolConfig
from praisonaiagents.config.presets import GUARDRAIL_PRESETS
def _ok(task_output):
return True, task_output
# --- GuardrailConfig.on_fail ------------------------------------------------
@pytest.mark.parametrize("on_fail", ["skip", "raise"])
def test_unsupported_on_fail_fails_loud(on_fail):
with pytest.raises(ValueError, match="on_fail"):
Agent(name="g", instructions="x", llm="gpt-4o-mini",
guardrails=GuardrailConfig(validator=_ok, on_fail=on_fail))
def test_retry_on_fail_still_accepted():
a = Agent(name="g", instructions="x", llm="gpt-4o-mini",
guardrails=GuardrailConfig(validator=_ok, max_retries=2, on_fail="retry"))
assert a.max_guardrail_retries == 2
def test_presets_carry_no_unimplemented_on_fail():
assert all("on_fail" not in p for p in GUARDRAIL_PRESETS.values())
a = Agent(name="g", instructions="x", llm="gpt-4o-mini",
guardrails=["strict", {"validator": _ok}])
assert a.max_guardrail_retries == 5
assert a.guardrail is _ok
# --- ToolConfig.output_max_lines / output_direction --------------------------
def many_lines() -> str:
"""Return 200 short lines."""
return "\n".join(f"line {i}" for i in range(200))
def _run_tool(cfg):
a = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[many_lines],
tool_config=cfg, output="silent")
return str(a.execute_tool("many_lines", {}))
def test_default_tool_config_does_not_cap_lines():
out = _run_tool(ToolConfig())
assert out.splitlines()[-1] == "line 199"
assert out.count("\n") == 199
def test_output_max_lines_head():
lines = _run_tool(ToolConfig(output_max_lines=5, output_direction="head")).splitlines()
assert lines[:5] == [f"line {i}" for i in range(5)]
assert "line 5" not in lines and "line 199" not in lines
assert "200 lines total" in lines[5]
def test_output_max_lines_tail():
lines = _run_tool(ToolConfig(output_max_lines=5, output_direction="tail")).splitlines()
assert lines[-5:] == [f"line {i}" for i in range(195, 200)]
assert "line 0" not in lines
def test_output_max_lines_both():
lines = _run_tool(ToolConfig(output_max_lines=4)).splitlines()
assert lines[:2] == ["line 0", "line 1"]
assert lines[-2:] == ["line 198", "line 199"]
assert len(lines) == 5 # 4 kept lines + marker
def test_output_direction_applies_to_char_cap():
out = _run_tool(ToolConfig(output_limit=50, output_direction="head"))
assert out.startswith("line 0\nline 1")
assert "line 199" not in out
def short_lines() -> str:
"""Return ten one-character lines."""
return "\n".join("abcdefghij")
def big_dict() -> dict:
"""Return a dict with one large field."""
return {"body": "START" + "x" * 45000 + "END"}
def test_line_cap_applies_to_short_lines():
a = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[short_lines],
tool_config=ToolConfig(output_max_lines=5, output_direction="head"), output="silent")
lines = str(a.execute_tool("short_lines", {})).splitlines()
assert lines[:5] == list("abcde")
assert "j" not in lines
def test_output_direction_applies_to_dict_results():
a = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[big_dict],
tool_config=ToolConfig(output_limit=40000, output_direction="head"), output="silent")
out = str(a.execute_tool("big_dict", {}))
assert "START" in out and "END" not in out
# --- rerank=True on Agent.rag -----------------------------------------------
class StubKnowledge:
"""Minimal Knowledge stand-in: search() returns hits worst-first."""
def add(self, *a, **k):
pass
def search(self, query, **kw):
return {"results": [
{"memory": "bananas are yellow", "score": 0.9, "metadata": {"source": "a"}},
{"memory": "paris is the capital of france", "score": 0.8, "metadata": {"source": "b"}},
]}
def _rag(retrieval_config=None):
a = Agent(name="r", instructions="x", llm="gpt-4o-mini", knowledge=StubKnowledge(),
retrieval_config=retrieval_config)
return a.rag
def test_rerank_true_reranks_and_reports_it():
pack = _rag({"rerank": True}).retrieve("capital of france")
assert pack.metadata["reranked"] is True
assert pack.context.index("paris") < pack.context.index("bananas")
def test_rerank_false_reports_not_reranked():
rag = _rag()
assert rag.reranker is None
assert rag.retrieve("capital of france").metadata["reranked"] is False
def test_rerank_override_without_reranker_reports_false():
assert _rag().retrieve("q", rerank=True).metadata["reranked"] is False
def test_rerank_model_leaves_reranking_to_backend():
rag = _rag({"rerank": True, "rerank_model": "cohere/rerank-v3"})
assert rag.reranker is None
def test_rerank_model_backend_reranking_reported():
class BackendRerankKnowledge(StubKnowledge):
config = {"reranker": {"default_rerank": True, "provider": "cohere"}}
a = Agent(name="r", instructions="x", llm="gpt-4o-mini", knowledge=BackendRerankKnowledge(),
retrieval_config={"rerank": True, "rerank_model": "cohere/rerank-v3"})
assert a.rag.reranker is None
assert a.rag.retrieve("q").metadata["reranked"] is True
assert a.rag.retrieve("q", rerank=False).metadata["reranked"] is False
@pytest.mark.asyncio
async def test_rerank_true_async():
pack = await _rag({"rerank": True}).aretrieve("capital of france")
assert pack.metadata["reranked"] is True
assert pack.context.index("paris") < pack.context.index("bananas")
def test_simple_reranker_accepts_search_result_items():
from praisonaiagents.knowledge.models import SearchResultItem
from praisonaiagents.rag.retriever import SimpleReranker
hits = [SearchResultItem(text="bananas are yellow", score=0.9),
SearchResultItem(text="paris is the capital of france", score=0.8)]
ranked = SimpleReranker().rerank("capital of france", hits)
assert ranked[0]["text"].startswith("paris")
@@ -0,0 +1,222 @@
"""Unit tests for evaluation utility functions."""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
from praisonaiagents.eval.utils import (
save_result_to_file,
load_result_from_file,
format_score,
format_percentage,
format_duration,
format_memory,
calculate_pass_rate,
)
class TestSaveResultToFile:
"""Tests for save_result_to_file function."""
def test_save_with_to_dict(self):
"""Test saving result with to_dict method."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0, "passed": True}
success = save_result_to_file(result, path)
assert success is True
assert Path(path).exists()
with open(path) as f:
data = json.load(f)
assert data["score"] == 8.0
def test_save_with_dict_attr(self):
"""Test saving result with __dict__ attribute."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
class SimpleResult:
def __init__(self):
self.score = 9.0
self.name = "test"
result = SimpleResult()
success = save_result_to_file(result, path)
assert success is True
with open(path) as f:
data = json.load(f)
assert data["score"] == 9.0
def test_save_with_placeholders(self):
"""Test saving with path placeholders."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/{{name}}_{{eval_id}}.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
success = save_result_to_file(
result, path,
eval_id="abc123",
name="test_eval"
)
assert success is True
expected_path = f"{tmpdir}/test_eval_abc123.json"
assert Path(expected_path).exists()
def test_save_creates_directories(self):
"""Test that save creates parent directories."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/nested/dir/result.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
success = save_result_to_file(result, path)
assert success is True
assert Path(path).exists()
def test_save_failure_returns_false(self):
"""Test that save failure returns False."""
result = MagicMock()
result.to_dict.side_effect = Exception("Test error")
success = save_result_to_file(result, "/invalid/path/result.json")
assert success is False
class TestLoadResultFromFile:
"""Tests for load_result_from_file function."""
def test_load_existing_file(self):
"""Test loading existing result file."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
data = {"score": 8.0, "passed": True}
with open(path, 'w') as f:
json.dump(data, f)
loaded = load_result_from_file(path)
assert loaded == data
def test_load_nonexistent_file(self):
"""Test loading nonexistent file returns None."""
result = load_result_from_file("/nonexistent/path/result.json")
assert result is None
def test_load_invalid_json(self):
"""Test loading invalid JSON returns None."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/invalid.json"
with open(path, 'w') as f:
f.write("not valid json")
result = load_result_from_file(path)
assert result is None
class TestFormatScore:
"""Tests for format_score function."""
def test_format_default_max(self):
"""Test formatting with default max score."""
assert format_score(8.5) == "8.5/10"
def test_format_custom_max(self):
"""Test formatting with custom max score."""
assert format_score(85.0, 100.0) == "85.0/100"
def test_format_zero(self):
"""Test formatting zero score."""
assert format_score(0.0) == "0.0/10"
class TestFormatPercentage:
"""Tests for format_percentage function."""
def test_format_full(self):
"""Test formatting 100%."""
assert format_percentage(1.0) == "100.0%"
def test_format_half(self):
"""Test formatting 50%."""
assert format_percentage(0.5) == "50.0%"
def test_format_zero(self):
"""Test formatting 0%."""
assert format_percentage(0.0) == "0.0%"
def test_format_decimal(self):
"""Test formatting decimal percentage."""
assert format_percentage(0.333) == "33.3%"
class TestFormatDuration:
"""Tests for format_duration function."""
def test_format_microseconds(self):
"""Test formatting microseconds."""
result = format_duration(0.0001)
assert "μs" in result
def test_format_milliseconds(self):
"""Test formatting milliseconds."""
result = format_duration(0.5)
assert "ms" in result
def test_format_seconds(self):
"""Test formatting seconds."""
result = format_duration(5.5)
assert "s" in result
assert "m" not in result
def test_format_minutes(self):
"""Test formatting minutes."""
result = format_duration(90.0)
assert "m" in result
class TestFormatMemory:
"""Tests for format_memory function."""
def test_format_kilobytes(self):
"""Test formatting kilobytes."""
result = format_memory(0.5)
assert "KB" in result
def test_format_megabytes(self):
"""Test formatting megabytes."""
result = format_memory(100.0)
assert "MB" in result
def test_format_gigabytes(self):
"""Test formatting gigabytes."""
result = format_memory(2048.0)
assert "GB" in result
class TestCalculatePassRate:
"""Tests for calculate_pass_rate function."""
def test_all_passed(self):
"""Test 100% pass rate."""
assert calculate_pass_rate(10, 10) == 1.0
def test_none_passed(self):
"""Test 0% pass rate."""
assert calculate_pass_rate(0, 10) == 0.0
def test_half_passed(self):
"""Test 50% pass rate."""
assert calculate_pass_rate(5, 10) == 0.5
def test_zero_total(self):
"""Test with zero total."""
assert calculate_pass_rate(0, 0) == 0.0
@@ -1,53 +0,0 @@
"""AFTER_LLM reports the usage counted by the existing completion path."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from openai.types.chat import ChatCompletion
import pytest
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import HooksConfig
from praisonaiagents.hooks import HookEvent, HookRegistry, HookResult
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("with_usage", [False, True])
def test_after_llm_receives_current_completion_usage(asynchronous, with_usage):
usage = {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
completion = ChatCompletion(
id="chatcmpl-fixture", created=1, object="chat.completion",
model="gpt-4o-mini",
choices=[{
"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "Fixture greeting."},
}],
usage=usage if with_usage else None,
)
observed = []
registry = HookRegistry()
def after_llm(event):
observed.append(event.tokens_used)
return HookResult.allow()
registry.register_function(HookEvent.AFTER_LLM, after_llm)
agent = Agent(
name="usage-fixture", instructions="Greet.", model="gpt-4o-mini",
api_key="synthetic-offline-fixture",
hooks=HooksConfig(registry=registry),
output={"verbose": False, "stream": False},
reflection=False, memory=False, rules=False, retry=False,
)
agent._unified_dispatcher = SimpleNamespace(
chat_completion=Mock(return_value=completion),
achat_completion=AsyncMock(return_value=completion),
)
if asynchronous:
result = asyncio.run(agent.achat("Hello", stream=False))
agent._unified_dispatcher.achat_completion.assert_awaited_once()
else:
result = agent.chat("Hello", stream=False)
agent._unified_dispatcher.chat_completion.assert_called_once()
assert result == "Fixture greeting."
assert observed == [18 if with_usage else 0]
@@ -275,113 +275,3 @@ def test_async_fallback_passes_override_and_leaves_shared_state(monkeypatch):
assert used[0] is not original_dispatcher
assert agent.llm == "gpt-4o-mini" # shared model untouched
assert agent._unified_dispatcher is original_dispatcher # shared cache untouched
# --- Custom-LLM path (Issue #6030) ---------------------------------------------
class _Unavailable(Exception):
status_code = 503
class _StubLLM:
def __init__(self, model, fail, calls):
self.model, self._fail, self._calls = model, fail, calls
def _respond(self):
self._calls.append(self.model)
if self._fail:
raise self._fail
return f"answer from {self.model}"
def get_response(self, **kwargs):
return self._respond()
async def get_response_async(self, **kwargs):
return self._respond()
class _ToolThenFailLLM(_StubLLM):
"""Runs a tool, then fails, like a 503 on the follow-up model request."""
def get_response(self, execute_tool_fn=None, **kwargs):
execute_tool_fn("send_email", {})
return self._respond()
def _custom_agent(primary_error, fallbacks):
from praisonaiagents import Agent
calls = []
primary = _StubLLM("primary-model", primary_error, calls)
agent = Agent(name="c", instructions="x", llm=primary, output="silent")
agent.fallback_models = [_StubLLM(m, None, calls) for m in fallbacks]
seen = []
agent._hook_runner.registry.register_function(
HookEvent.MODEL_FALLBACK, lambda inp: seen.append(inp) or HookResult.allow()
)
return agent, primary, calls, seen
def test_custom_llm_sync_falls_back_on_outage():
agent, primary, calls, seen = _custom_agent(_Unavailable("503 overloaded"), ["backup"])
assert agent.chat("hello") == "answer from backup"
assert calls == ["primary-model", "backup"]
assert len(seen) == 1 and seen[0].fallback_index == 0
assert agent.llm_instance is primary # shared state untouched
def test_custom_llm_async_falls_back_on_outage():
agent, primary, calls, seen = _custom_agent(_Unavailable("503 overloaded"), ["backup"])
assert asyncio.run(agent.achat("hello")) == "answer from backup"
assert calls == ["primary-model", "backup"]
assert len(seen) == 1
assert agent.llm_instance is primary
def test_custom_llm_non_fallback_error_does_not_walk_chain():
err = Exception("401 Unauthorized: invalid api key")
err.status_code = 401
agent, _, calls, seen = _custom_agent(err, ["backup"])
assert agent.chat("hello") is None
assert calls == ["primary-model"]
assert seen == []
def test_fallback_llm_for_drops_connection_settings_across_providers(monkeypatch):
import praisonaiagents.llm.llm as llm_mod
built = []
monkeypatch.setattr(llm_mod, "LLM", lambda **kw: built.append(kw) or kw)
agent = _DummyAgent()
agent.llm = "ollama/llama3"
agent._llm_init_params = {"model": "ollama/llama3", "base_url": "http://h", "api_key": "k", "max_iter": 3}
agent._fallback_llm_for("ollama/qwen")
agent._fallback_llm_for("openai/gpt-4o-mini")
assert built[0] == {"model": "ollama/qwen", "base_url": "http://h", "api_key": "k", "max_iter": 3}
assert built[1] == {"model": "openai/gpt-4o-mini", "max_iter": 3}
assert agent._llm_init_params["model"] == "ollama/llama3"
def test_fallback_llm_for_keeps_connection_settings_for_bare_names(monkeypatch):
import praisonaiagents.llm.llm as llm_mod
built = []
monkeypatch.setattr(llm_mod, "LLM", lambda **kw: built.append(kw) or kw)
agent = _DummyAgent()
agent.llm = "model-a"
agent._llm_init_params = {"model": "model-a", "base_url": "http://custom/v1", "api_key": "k"}
agent._fallback_llm_for("model-b")
assert built[0] == {"model": "model-b", "base_url": "http://custom/v1", "api_key": "k"}
def test_custom_llm_does_not_fall_back_after_a_tool_ran():
from praisonaiagents import Agent
calls, sent = [], []
primary = _ToolThenFailLLM("primary-model", _Unavailable("503 overloaded"), calls)
agent = Agent(name="c", instructions="x", llm=primary, output="silent")
agent.fallback_models = [_StubLLM("backup", None, calls)]
agent.execute_tool = lambda name, args, **kw: sent.append(name)
assert agent.chat("hello") is None # existing error handling, no replay
assert calls == ["primary-model"]
assert sent == ["send_email"]
@@ -1,94 +0,0 @@
"""A supplied memory adapter must not require the default implementation."""
import copy
import sys
import types
import pytest
pytest.importorskip("litellm", reason="optional LLM provider dependency")
from praisonaiagents.llm import LLM
class ProvidedMemory:
"""Side-effect-free implementation of the supplied memory adapter surface."""
def get_tool_definition(self):
"""Return the request tool definition without constructing a backend."""
return {"type": "memory_20250818", "name": "memory"}
def get_beta_header(self):
"""Return a synthetic beta marker for local parameter checks."""
return "synthetic-memory-fixture-beta"
def execute(self, **kwargs):
"""Provide the adapter execution surface without I/O."""
return "fixture memory result"
def make_llm(memory, **kwargs):
"""Construct the real params producer with an explicit synthetic key."""
return LLM(
model="anthropic/claude-sonnet-4-20250514",
api_key="synthetic-offline-fixture",
base_url="http://127.0.0.1:1",
claude_memory=memory,
**kwargs,
)
def test_supplied_memory_is_used_without_default_module(monkeypatch):
"""A supplied instance is reused even if the default cannot be imported."""
monkeypatch.setitem(sys.modules, "praisonaiagents.tools.claude_memory_tool", None)
supplied = ProvidedMemory()
llm = make_llm(supplied)
assert llm._get_claude_memory_tool() is supplied
assert llm._get_claude_memory_tool() is supplied
@pytest.mark.parametrize("tools, headers", [
(None, None),
([], {}),
([{"type": "function", "function": {"name": "fixture", "parameters": {"type": "object", "properties": {}}}}], {"x-fixture": "retained"}),
])
def test_real_params_builder_accepts_supplied_memory_without_default_module(monkeypatch, tools, headers):
"""Supplied memory is included without ordering assumptions or input mutation."""
monkeypatch.setitem(sys.modules, "praisonaiagents.tools.claude_memory_tool", None)
supplied = ProvidedMemory()
llm = make_llm(supplied, web_fetch=True)
original_tools, original_headers = copy.deepcopy(tools), copy.deepcopy(headers)
first = llm._build_completion_params(tools=tools, extra_headers=headers)
second = llm._build_completion_params(tools=tools, extra_headers=headers)
assert supplied.get_tool_definition() in first["tools"]
assert first["tools"] == second["tools"]
assert supplied.get_beta_header() in first["extra_headers"]["anthropic-beta"]
assert tools == original_tools
assert headers == original_headers
@pytest.mark.parametrize("memory", [None, False])
def test_disabled_memory_still_needs_no_implementation(monkeypatch, memory):
"""Disabled memory never imports an implementation."""
monkeypatch.setitem(sys.modules, "praisonaiagents.tools.claude_memory_tool", None)
assert make_llm(memory)._get_claude_memory_tool() is None
def test_boolean_opt_in_still_constructs_and_caches_default(monkeypatch):
"""Boolean opt-in retains default factory construction and caching."""
calls = []
class DefaultMemory(ProvidedMemory):
"""Optional implementation stand-in that records construction."""
def __init__(self):
"""Record one default factory invocation."""
calls.append(self)
module = types.ModuleType("praisonaiagents.tools.claude_memory_tool")
module.ClaudeMemoryTool = DefaultMemory
monkeypatch.setitem(sys.modules, module.__name__, module)
llm = make_llm(True)
tool = llm._get_claude_memory_tool()
assert isinstance(tool, DefaultMemory)
assert llm._get_claude_memory_tool() is tool
assert calls == [tool]
@@ -1,80 +0,0 @@
from datetime import datetime, timezone
from praisonaiagents.memory.learn.stores import InsightStore, LearnEntry
def test_new_entry_timestamps_are_timezone_aware():
"""New learn entries carry timezone-aware created_at and updated_at."""
entry = LearnEntry(id="e1", content="hello")
assert datetime.fromisoformat(entry.created_at).tzinfo is not None
assert datetime.fromisoformat(entry.updated_at).tzinfo is not None
def test_prune_handles_legacy_naive_timestamps(tmp_path):
"""Pruning treats legacy naive timestamps as UTC instead of raising on the subtraction."""
store = InsightStore(store_path=str(tmp_path / "store.json"), retention_days=1)
now = datetime.now(timezone.utc)
# An entry persisted before the timezone-aware change: naive isoformat, no offset.
legacy = LearnEntry(
id="old",
content="old",
created_at="2020-01-01T00:00:00",
updated_at="2020-01-01T00:00:00",
)
# Must not raise "can't subtract offset-naive and offset-aware datetimes".
assert store._is_stale(legacy, now) is True
fresh = LearnEntry(id="new", content="new")
assert store._is_stale(fresh, now) is False
def test_prune_evicts_stale_legacy_entry_end_to_end(tmp_path):
"""prune() archives a stale legacy (naive-timestamp) entry without raising."""
store = InsightStore(store_path=str(tmp_path / "store.json"), retention_days=1)
store._entries["old"] = LearnEntry(
id="old",
content="old",
created_at="2020-01-01T00:00:00",
updated_at="2020-01-01T00:00:00",
)
assert store.prune(save=False) == 1
assert "old" not in store._entries
def test_list_all_orders_by_instant_not_raw_string(tmp_path):
"""list_all() orders by the actual UTC instant, even when offsets differ.
00:30+01:00 is an earlier instant than 00:00+00:00, but a raw-string sort
would order them the other way round.
"""
store = InsightStore(store_path=str(tmp_path / "store.json"))
store._entries = {
"earlier": LearnEntry(id="earlier", content="e", updated_at="2024-01-01T00:30:00+01:00"),
"later": LearnEntry(id="later", content="l", updated_at="2024-01-01T00:00:00+00:00"),
}
store._save() # persist so list_all's refresh reloads these entries
ordered = [e.id for e in store.list_all()] # reverse=True: most recent first
assert ordered == ["later", "earlier"]
def test_prune_evicts_never_used_entry_before_used_one(tmp_path):
"""At equal use_count, a never-used entry (last_used=None) is evicted first."""
store = InsightStore(store_path=str(tmp_path / "store.json"), max_entries=1)
store._entries = {
"never": LearnEntry(
id="never", content="n", use_count=0, last_used=None,
created_at="2024-01-01T00:00:00+00:00",
),
"used": LearnEntry(
id="used", content="u", use_count=0,
last_used="2024-06-01T00:00:00+00:00",
created_at="2024-01-01T00:00:00+00:00",
),
}
store.prune(save=False)
assert "never" not in store._entries
assert "used" in store._entries
@@ -1,62 +0,0 @@
"""Input validation must not run blocking validators on the achat event loop."""
import asyncio
import threading
import pytest
from praisonaiagents import Agent
class RecordingInputGuardrail:
"""Reject before provider dispatch while recording the execution thread."""
def __init__(self):
self.thread_id = None
self.content = None
def validate_input(self, content, **kwargs):
self.thread_id = threading.get_ident()
self.content = content
return False, "blocked by test validator"
def validate_output(self, content, **kwargs):
return True, content
def test_achat_offloads_sync_input_guardrail():
"""Public achat invokes a synchronous validator outside its event-loop thread."""
guardrail = RecordingInputGuardrail()
agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=guardrail)
async def run():
loop_thread = threading.get_ident()
await agent.achat("blocked prompt")
assert guardrail.content == "blocked prompt"
assert guardrail.thread_id is not None
assert guardrail.thread_id != loop_thread
asyncio.run(run())
@pytest.mark.parametrize("guardrails", [None, "Check output quality", lambda output: (True, output)])
def test_achat_disabled_guardrail_keeps_fast_path(monkeypatch, guardrails):
"""Absent, callable and output-only guards need no worker-thread dispatch."""
agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=guardrails)
guardrail = RecordingInputGuardrail()
def record(prompt):
guardrail.validate_input(prompt)
return False, None, "stop before provider dispatch"
monkeypatch.setattr(agent, "_validate_input_with_guardrail", record)
asyncio.run(agent.achat("blocked prompt"))
assert guardrail.thread_id == threading.get_ident()
def test_chat_keeps_sync_input_guardrail_on_caller_thread():
"""The synchronous public API retains its existing execution context."""
guardrail = RecordingInputGuardrail()
agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=guardrail)
agent.chat("blocked prompt")
assert guardrail.content == "blocked prompt"
assert guardrail.thread_id == threading.get_ident()
@@ -1,61 +0,0 @@
"""Disabled handoffs do not load their implementation; enabled paths still convert."""
import sys
import pytest
from praisonaiagents import Agent
from praisonaiagents.agent.handoff import Handoff
def make_agent(name, **kwargs):
"""Construct a minimal offline agent for handoff conversion checks."""
return Agent(
name=name,
instructions="Return a short answer.",
api_key="synthetic-offline-fixture",
reflection=False,
memory=False,
knowledge=False,
**kwargs,
)
@pytest.mark.parametrize("tools", [None, []])
def test_disabled_handoffs_do_not_require_handoff_module(monkeypatch, tools):
"""Empty tool/handoff configuration must not load handoff support."""
# A blocked import makes an unnecessary local import observable even when
# another test has already loaded this module in the same interpreter.
monkeypatch.setitem(sys.modules, "praisonaiagents.agent.handoff", None)
agent = make_agent("no-handoff", tools=tools)
assert agent.tools == []
assert agent.handoffs == []
@pytest.mark.parametrize("entry", ["tools", "handoffs"])
def test_handoff_objects_still_convert_to_bound_tools(entry):
"""Both supported Handoff entry points retain their bound source object."""
target = make_agent("target")
source = Handoff(target)
agent = make_agent("parent", **{entry: [source]})
assert len(agent.tools) == 1
assert callable(agent.tools[0])
assert agent.tools[0]._praison_handoff_source is source
def test_direct_agent_handoff_still_converts_to_bound_tool():
"""A direct Agent handoff still creates a tool for that target."""
target = make_agent("target")
agent = make_agent("parent", handoffs=[target])
assert len(agent.tools) == 1
assert callable(agent.tools[0])
assert agent.tools[0]._praison_handoff_source.agent is target
def test_regular_tool_is_unchanged():
"""Ordinary callable tools remain unchanged by handoff conversion."""
def echo(text: str) -> str:
"""Return the input text."""
return text
agent = make_agent("regular-tool", tools=[echo])
assert agent.tools == [echo]
@@ -1,31 +0,0 @@
"""OpenAI image calls must only forward style to models that accept it (dall-e-3)."""
import pytest
from praisonaiagents import ImageAgent
@pytest.mark.parametrize("llm,kwargs,expected", [
("openai/dall-e-2", {}, None),
("openai/gpt-image-1", {}, None),
("openai/gpt-image-1", {"style": "vivid"}, None),
("openai/dall-e-3", {}, "natural"),
("openai/dall-e-3", {"style": "vivid"}, "vivid"),
])
def test_style_forwarding(llm, kwargs, expected):
agent = ImageAgent(llm=llm, verbose=False)
received = {}
def fake_image_generation(**call_kwargs):
received.update(call_kwargs)
return {"data": [{"url": "https://example.com/image.png"}]}
agent._litellm = fake_image_generation
try:
agent.generate("A small green square icon", **kwargs)
if expected is None:
assert "style" not in received
else:
assert received["style"] == expected
finally:
agent.close()
@@ -1,139 +0,0 @@
"""Regression tests for issue #5957 (doom-loop window, download_file SSRF/size cap)."""
import socket
import time
from unittest.mock import MagicMock, patch
import pytest
from praisonaiagents.approval import reset_yaml_approved_tools, set_yaml_approved_tools
from praisonaiagents.permissions.doom_loop import DoomLoopDetector
from praisonaiagents.tools import url_safety
def test_doom_loop_tool_count_is_windowed():
detector = DoomLoopDetector(window_seconds=0.05, max_tool_calls=10)
# One call per window with fresh arguments is not a loop.
for i in range(8):
result = detector.record_and_check("read_file", {"path": f"f{i}"})
assert not result.is_loop, result.reason
time.sleep(0.06)
# Lifetime totals are still reported for stats.
assert detector.get_stats()["call_counts"]["read_file"] == 8
def test_doom_loop_tool_count_still_trips_within_window():
detector = DoomLoopDetector(window_seconds=60, max_tool_calls=10)
for i in range(5):
detector.record("read_file", {"path": f"f{i}"})
result = detector.check("read_file", {"path": "new"})
assert result.is_loop
assert "excessively" in result.reason
def _addrinfo(ip):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))]
@pytest.mark.parametrize("ip", ["100.100.100.200", "100.64.0.1"])
def test_url_safety_blocks_shared_address_space(ip, monkeypatch):
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
with patch.object(url_safety.socket, "getaddrinfo", return_value=_addrinfo(ip)):
assert not url_safety.is_safe_http_url(f"http://{ip}/latest/meta-data/")
def test_url_safety_allows_public_address(monkeypatch):
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
with patch.object(url_safety.socket, "getaddrinfo", return_value=_addrinfo("93.184.216.34")):
assert url_safety.is_safe_http_url("https://example.com/")
@pytest.fixture
def file_tools(tmp_path, monkeypatch):
pytest.importorskip("httpx")
from praisonaiagents.tools.file_tools import FileTools
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
tools = FileTools()
monkeypatch.setattr(tools, "_require_workspace_access", lambda write=False: None)
monkeypatch.setattr(tools, "_validate_path", lambda p: str(tmp_path / p))
token = set_yaml_approved_tools(["download_file"])
try:
yield tools
finally:
reset_yaml_approved_tools(token)
@pytest.mark.parametrize("url", [
"http://localhost./x",
"http://169.254.169.254./x",
"http://0177.0.0.1/x",
])
def test_download_file_uses_shared_ssrf_guard(file_tools, url):
with patch("httpx.stream") as stream:
result = file_tools.download_file(url, "out.bin")
assert not result["success"]
stream.assert_not_called()
def test_download_file_ignores_allow_local_crawl(file_tools, monkeypatch):
monkeypatch.setenv("ALLOW_LOCAL_CRAWL", "true")
with patch("httpx.stream") as stream:
result = file_tools.download_file("http://127.0.0.1/x", "out.bin")
assert not result["success"]
stream.assert_not_called()
def test_download_file_fails_closed_on_unresolvable_host(file_tools):
with patch.object(url_safety.socket, "getaddrinfo", side_effect=socket.gaierror), \
patch("httpx.stream") as stream:
result = file_tools.download_file("http://does-not-resolve.invalid/x", "out.bin")
assert not result["success"]
stream.assert_not_called()
def test_download_file_enforces_byte_cap(file_tools, tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_MAX_DOWNLOAD_BYTES", "10000")
response = MagicMock()
response.headers = {}
response.iter_bytes.return_value = iter([b"x" * 8192] * 10)
ctx = MagicMock()
ctx.__enter__.return_value = response
with patch.object(url_safety.socket, "getaddrinfo", return_value=_addrinfo("93.184.216.34")), \
patch("httpx.stream", return_value=ctx):
result = file_tools.download_file("https://example.com/big", "big.bin")
assert not result["success"]
assert "exceeds" in result["error"]
assert not (tmp_path / "big.bin").exists()
def _stream_ctx(chunks):
response = MagicMock()
response.headers = {}
response.iter_bytes.return_value = iter(chunks)
ctx = MagicMock()
ctx.__enter__.return_value = response
return ctx
def test_download_file_cap_keeps_existing_file(file_tools, tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_MAX_DOWNLOAD_BYTES", "10000")
(tmp_path / "keep.bin").write_bytes(b"original")
with patch.object(url_safety.socket, "getaddrinfo", return_value=_addrinfo("93.184.216.34")), \
patch("httpx.stream", return_value=_stream_ctx([b"x" * 8192] * 10)):
result = file_tools.download_file("https://example.com/big", "keep.bin")
assert not result["success"]
assert (tmp_path / "keep.bin").read_bytes() == b"original"
assert sorted(p.name for p in tmp_path.iterdir()) == ["keep.bin"]
def test_download_file_success_replaces_destination(file_tools, tmp_path):
(tmp_path / "out.bin").write_bytes(b"old")
with patch.object(url_safety.socket, "getaddrinfo", return_value=_addrinfo("93.184.216.34")), \
patch("httpx.stream", return_value=_stream_ctx([b"new", b"data"])):
result = file_tools.download_file("https://example.com/f", "out.bin")
assert result["success"]
assert result["size"] == 7
assert (tmp_path / "out.bin").read_bytes() == b"newdata"
assert sorted(p.name for p in tmp_path.iterdir()) == ["out.bin"]
@@ -278,58 +278,3 @@ class TestTelemetryConfig:
if __name__ == "__main__":
pytest.main([__file__, "-v"])
def _fresh_python(code):
"""Run ``code`` in a fresh interpreter against this checkout; return stdout."""
import os
import subprocess
from pathlib import Path
root = str(Path(__file__).resolve().parents[2])
env = dict(os.environ, PYTHONPATH=root, PYTHONDONTWRITEBYTECODE="1")
out = subprocess.run([sys.executable, "-c", code], env=env, capture_output=True,
text=True, timeout=120, check=True)
return out.stdout.strip().splitlines()[-1]
class TestReplayStaysLazy:
"""PraisonAI Replay's graph builder must cost nothing until a trace is on."""
def test_package_import_does_not_load_the_graph_builder(self):
loaded = _fresh_python(
"import sys, praisonaiagents;"
"from praisonaiagents import Agent, AgentTeam, AgentFlow;"
"print('praisonaiagents.workflows.diagram' in sys.modules)"
)
assert loaded == "False"
def test_an_untraced_run_does_not_load_the_graph_builder(self):
loaded = _fresh_python(
"import sys\n"
"from praisonaiagents import AgentFlow\n"
"def step(ctx): return 'ok'\n"
"AgentFlow(steps=[step]).run('go');"
"print('praisonaiagents.workflows.diagram' in sys.modules)"
)
assert loaded == "False"
def test_a_traced_run_builds_the_graph(self):
has_graph = _fresh_python(
"from praisonaiagents import AgentFlow\n"
"from praisonaiagents.trace.context_events import ContextListSink, ContextTraceEmitter, trace_context\n"
"sink = ContextListSink()\n"
"def step(ctx): return 'ok'\n"
"with trace_context(ContextTraceEmitter(sink=sink, session_id='s')):\n"
" AgentFlow(steps=[step]).run('go')\n"
"print(sink.get_events()[0].data['graph']['schema'])"
)
assert has_graph == "praisonai.graph/1"
def test_package_import_does_not_load_heavy_optional_modules(self):
"""Deterministic stand-in for the <200 ms budget: no heavy optional deps."""
loaded = _fresh_python(
"import sys, praisonaiagents;"
"print(sorted(m for m in ('litellm', 'chromadb') if m in sys.modules))"
)
assert loaded == "[]"
@@ -1,173 +0,0 @@
"""Task and Workflow step output_file writes must stay inside the project root."""
import asyncio
import os
import pytest
from praisonaiagents import (
Agent,
AgentTeam,
StepResult,
Task,
Workflow,
WorkflowContext,
)
@pytest.fixture
def root(tmp_path, monkeypatch):
work = tmp_path / "work"
work.mkdir()
monkeypatch.chdir(work)
monkeypatch.delenv("PRAISONAI_PROJECT_ROOT", raising=False)
return work
def _team(output_file, create_directory=True):
agent = Agent(name="a", instructions="test")
task = Task(
description="d",
expected_output="e",
agent=agent,
output_file=output_file,
create_directory=create_directory,
)
return AgentTeam(agents=[agent], tasks=[task]), task
def _workflow(output_file):
def generate(ctx: WorkflowContext) -> StepResult:
return StepResult(output="report")
return Workflow(steps=[Task(name="gen", handler=generate, output_file=output_file)])
def test_task_output_file_traversal_blocked(root):
team, task = _team("../escaped/out.txt")
team.save_output_to_file(task, "PWNED")
assert not (root.parent / "escaped").exists()
def test_task_output_file_symlink_escape_blocked(root, tmp_path):
outside = tmp_path / "outside"
outside.mkdir()
os.symlink(outside, root / "link")
team, task = _team("link/out.txt")
team.save_output_to_file(task, "PWNED")
assert not (outside / "out.txt").exists()
def test_task_output_file_inside_root(root):
team, task = _team("sub/out.txt")
team.save_output_to_file(task, "héllo 世界")
assert (root / "sub" / "out.txt").read_text(encoding="utf-8") == "héllo 世界"
team, task = _team(str(root / "abs.txt"))
team.save_output_to_file(task, "ok")
assert (root / "abs.txt").read_text() == "ok"
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
def test_task_output_file_overwrite_preserves_permissions(root):
target = root / "shared.txt"
target.write_text("old")
os.chmod(target, 0o644)
team, task = _team("shared.txt")
team.save_output_to_file(task, "new")
assert target.read_text() == "new"
assert target.stat().st_mode & 0o777 == 0o644
@pytest.mark.skipif(os.name == "nt", reason="POSIX hardlinks")
def test_task_output_file_hardlink_does_not_write_outside(root, tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("secret")
os.link(outside, root / "linked.txt")
team, task = _team("linked.txt")
team.save_output_to_file(task, "PWNED")
assert (root / "linked.txt").read_text() == "PWNED"
assert outside.read_text() == "secret"
@pytest.mark.skipif(os.name == "nt", reason="POSIX hardlinks")
def test_task_output_file_hardlink_keeps_mode_under_umask(root, tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("secret")
os.chmod(outside, 0o664)
os.link(outside, root / "linked.txt")
team, task = _team("linked.txt")
old_umask = os.umask(0o077)
try:
team.save_output_to_file(task, "new")
finally:
os.umask(old_umask)
assert (root / "linked.txt").stat().st_mode & 0o777 == 0o664
assert outside.read_text() == "secret"
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="POSIX permission bits")
def test_task_output_file_overwrite_in_readonly_directory(root):
(root / "ro").mkdir()
target = root / "ro" / "out.txt"
target.write_text("old")
os.chmod(root / "ro", 0o555)
try:
team, task = _team("ro/out.txt")
team.save_output_to_file(task, "new")
finally:
os.chmod(root / "ro", 0o755)
assert target.read_text() == "new"
def test_task_output_file_respects_create_directory(root):
team, task = _team("missing/out.txt", create_directory=False)
team.save_output_to_file(task, "ok")
assert not (root / "missing").exists()
def test_task_output_file_project_root_override(root, tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_PROJECT_ROOT", str(tmp_path))
team, task = _team(str(tmp_path / "shared" / "out.txt"))
team.save_output_to_file(task, "ok")
assert (tmp_path / "shared" / "out.txt").read_text() == "ok"
def test_workflow_output_file_input_traversal_blocked(root):
_workflow("reports/{{input}}.md").start("../../x")
assert not (root.parent / "x.md").exists()
_workflow("reports/{{input}}.md").start("ok")
assert (root / "reports" / "ok.md").read_text() == "report"
def test_workflow_async_output_file_input_traversal_blocked(root):
asyncio.run(_workflow("reports/{{input}}.md").arun("../../x"))
assert not (root.parent / "x.md").exists()
asyncio.run(_workflow("reports/{{input}}.md").arun("ok"))
assert (root / "reports" / "ok.md").read_text() == "report"
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
def test_task_output_file_traversal_blocked_real_run(root):
team, _ = _team("../escaped.txt")
team.start()
assert not (root.parent / "escaped.txt").exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX hardlinks")
def test_task_output_file_hardlink_group_kept_or_bits_dropped(root, tmp_path, monkeypatch):
outside = tmp_path / "outside.txt"
outside.write_text("secret")
os.chmod(outside, 0o660)
os.link(outside, root / "linked.txt")
def deny(*args):
raise PermissionError
monkeypatch.setattr(os, "fchown", deny)
team, task = _team("linked.txt")
team.save_output_to_file(task, "new")
assert (root / "linked.txt").read_text() == "new"
assert (root / "linked.txt").stat().st_mode & 0o777 == 0o600
assert outside.read_text() == "secret"
@@ -1,179 +0,0 @@
"""Regression tests for #5980.
- A second AgentTeam.start()/astart() must re-execute tasks, not return stale output.
- RAG.retrieve(top_k=...) must not mutate the shared RAGConfig.
- SmartRetriever must over-fetch before post-filtering, fail closed on a bad
path_filter regex, and not share store errors across calls via instance state.
"""
import asyncio
import threading
import pytest
from praisonaiagents import Agent, AgentTeam, Task
from praisonaiagents.knowledge.query_engine import SubQuestionEngine
from praisonaiagents.rag.models import RAGConfig
from praisonaiagents.rag.pipeline import RAG
from praisonaiagents.rag.retriever import SmartRetriever
def _team():
calls = []
agent = Agent(name="a", instructions="x", llm="gpt-4o-mini")
def chat(prompt, **kw):
calls.append(prompt)
return f"answer#{len(calls)}"
async def achat(prompt, **kw):
return chat(prompt)
agent.chat = chat
agent.achat = achat
task = Task(name="t1", description="Summarise the ticket", expected_output="e", agent=agent)
return AgentTeam(agents=[agent], tasks=[task], process="sequential"), task, calls
def test_second_start_reexecutes_and_drops_previous_content():
team, task, calls = _team()
assert team.start("ticket A", output="silent") == "answer#1"
assert team.start("ticket B", output="silent") == "answer#2"
assert len(calls) == 2
assert task.context == ["ticket B"]
def test_second_astart_reexecutes():
team, _, calls = _team()
async def run():
return await team.astart(), await team.astart()
assert asyncio.run(run()) == ("answer#1", "answer#2")
assert len(calls) == 2
def test_restored_checkpoint_is_resumed_not_reset():
team, task, calls = _team()
team.start(output="silent")
snapshot = team._serialisable_task_state()
assert team._restore_serialised_task_state(snapshot) == 1
team.start(output="silent")
assert len(calls) == 1 # completed task restored -> skipped
assert task.status == "completed"
def test_rerun_keeps_caller_defined_task_variables():
team, task, calls = _team()
task.description = "Summarise {{ticket}}"
task.variables = {"ticket": "T-1"}
team.start(output="silent")
team.start(output="silent")
assert task.variables == {"ticket": "T-1"}
assert all("T-1" in str(p) and "{{ticket}}" not in str(p) for p in calls)
def test_rerun_repropagates_current_team_variables():
team, task, calls = _team()
task.description = "Summarise {{ticket}}"
team.variables = {"ticket": "T-1"}
team.start(output="silent")
team.variables = {"ticket": "T-2"}
team.start(output="silent")
assert "T-2" in str(calls[1])
def test_team_variables_repropagate_after_batch():
team, task, calls = _team()
task.description = "Summarise {{ticket}}"
team.variables = {"ticket": "T-1"}
team.start(output="silent")
team.start_for_each([{"ticket": "X"}])
team.variables = {"ticket": "T-2"}
team.start(output="silent")
assert "T-2" in str(calls[-1])
def test_fresh_run_after_resume_drops_earlier_content():
team, task, calls = _team()
team.start("ticket A", output="silent")
team._restore_serialised_task_state(team._serialisable_task_state())
team.start(output="silent") # resume: completed task skipped
team.start("ticket B", output="silent")
assert task.context == ["ticket B"]
def test_rag_retrieve_override_does_not_mutate_config():
# Hold the top_k=1 call inside search() until a default call has finished,
# so the default call is guaranteed to run while the override is active.
override_in_search = threading.Event()
default_done = threading.Event()
class Knowledge:
def search(self, query, **kw):
if query == "override":
override_in_search.set()
default_done.wait(2)
return [{"text": f"d{i}", "score": 1.0, "metadata": {}} for i in range(20)]
rag = RAG(knowledge=Knowledge(), config=RAGConfig(top_k=5))
out = {}
ta = threading.Thread(target=lambda: out.setdefault("a", rag.retrieve("override", top_k=1)))
ta.start()
assert override_in_search.wait(2)
out["b"] = rag.retrieve("default")
default_done.set()
ta.join()
assert rag.config.top_k == 5
assert out["a"].metadata["top_k"] == 1
assert out["b"].metadata["top_k"] == 5
class _MixedStore:
def search(self, query, limit=5, **kw):
docs = [{"text": f"p{i}", "metadata": {"filename": f"p{i}.pdf"}, "score": 1 - i / 100}
for i in range(10)]
docs += [{"text": f"m{i}", "metadata": {"filename": f"m{i}.md"}, "score": 0.5}
for i in range(3)]
return docs[:limit]
def test_filters_over_fetch_before_truncation():
result = SmartRetriever(_MixedStore()).retrieve("q", top_k=5, include_glob=["*.md"])
assert [c["text"] for c in result.chunks] == ["m0", "m1", "m2"]
def test_invalid_path_filter_fails_closed():
with pytest.raises(ValueError):
SmartRetriever(_MixedStore()).retrieve("q", path_filter="([unclosed")
def test_store_error_is_not_shared_across_concurrent_calls():
# Hold the good call inside search() until the failing call has fully
# recorded its error, the interleaving that leaked it via instance state.
good_in_search = threading.Event()
bad_done = threading.Event()
class Store:
def search(self, query, **kw):
if query == "bad":
raise RuntimeError("down")
good_in_search.set()
bad_done.wait(2)
return [{"text": "ok", "metadata": {}, "score": 1.0}]
retriever = SmartRetriever(Store())
out = {}
tg = threading.Thread(target=lambda: out.setdefault("good", retriever.retrieve("good")))
tg.start()
assert good_in_search.wait(2)
out["bad"] = retriever.retrieve("bad")
bad_done.set()
tg.join()
assert out["bad"].retrieval_failed is True
assert out["good"].retrieval_failed is False
def test_sub_question_engine_accepts_smart_retriever():
result = SubQuestionEngine().query("what is m?", retriever=SmartRetriever(_MixedStore()))
assert result.sources
@@ -5,13 +5,11 @@ import pytest
from praisonaiagents.tools import github_tools
def _fake_git_factory(responses, calls, cwds=None):
def _fake_git_factory(responses, calls):
"""Build a fake subprocess.run that records calls and serves canned output."""
cwds = cwds if cwds is not None else []
def fake_run(cmd, check=False, capture_output=False, text=False, cwd=None):
def fake_run(cmd, check=False, capture_output=False, text=False):
calls.append(cmd)
cwds.append(cwd)
assert cmd[0] == "git"
key = tuple(cmd[1:])
# Match on a prefix of the git args
@@ -258,50 +256,3 @@ def test_create_branch_accepts_slash_names(monkeypatch):
checkouts = [c for c in calls if c[:2] == ["git", "checkout"]]
assert checkouts, "expected a checkout for a valid branch name"
assert ["git", "checkout", "-B", "feature/new-thing"] in checkouts
def test_repo_path_used_as_cwd_for_every_git_call(monkeypatch):
responses = {
("status", "--porcelain"): ("M file\n", 0),
("rev-parse", "--abbrev-ref", "HEAD"): ("praisonai/work\n", 0),
("symbolic-ref", "refs/remotes/origin/HEAD"): ("refs/remotes/origin/main\n", 0),
}
calls, cwds = [], []
monkeypatch.setattr(subprocess, "run", _fake_git_factory(responses, calls, cwds))
result = _run_tool(github_tools.github_commit_and_push, "fix", repo_path="/repo")
assert "Successfully" in result
assert calls and all(c == "/repo" for c in cwds)
def test_never_stages_untracked_with_add_dot(monkeypatch):
responses = {
("status", "--porcelain"): ("M file\n", 0),
("rev-parse", "--abbrev-ref", "HEAD"): ("praisonai/work\n", 0),
("symbolic-ref", "refs/remotes/origin/HEAD"): ("refs/remotes/origin/main\n", 0),
}
calls = _call(monkeypatch, responses)
_run_tool(github_tools.github_commit_and_push, "fix", paths=["new.py"])
adds = [c for c in calls if c[:2] == ["git", "add"]]
# scoped to cwd so a subdirectory call cannot stage sibling edits
assert ["git", "add", "-u", "--", "."] in adds
assert ["git", "add", "-u"] not in adds
assert ["git", "add", "--", "new.py"] in adds
assert ["git", "add", "."] not in adds
# untracked files are not considered when checking for changes
status = [c for c in calls if c[:2] == ["git", "status"]]
assert all("--untracked-files=no" in c for c in status)
def test_refusal_leaves_index_untouched(monkeypatch):
responses = {
("status", "--porcelain"): ("M file\n", 0),
("rev-parse", "--abbrev-ref", "HEAD"): ("release/v2\n", 0),
("symbolic-ref", "refs/remotes/origin/HEAD"): ("refs/remotes/origin/main\n", 0),
("config", "user.email"): ("[email protected]\n", 0),
("merge-base", "origin/main", "HEAD"): ("abc\n", 0),
("log", "--format=%ae", "abc..HEAD"): ("[email protected]\n", 0),
}
calls = _call(monkeypatch, responses)
result = _run_tool(github_tools.github_commit_and_push, "fix", branch="release/v2")
assert "Error" in result
assert not [c for c in calls if c[:2] == ["git", "add"]]
@@ -1,939 +0,0 @@
"""PraisonAI Replay: run-structure events on the context trace (schema 1.1).
A replay can only explain a run if the trace says which run, which task or
step, which graph node and which enclosing span every event belongs to, and if
concurrent branches cannot scramble that. These cover the event schema, the
span/branch bookkeeping, and every place the SDK now records what happened:
hand-offs, team tasks, flow steps, manager decisions, approvals and the
LiteLLM call path. No test here reaches a model.
"""
import asyncio
import time
import types
import pytest
from praisonaiagents.trace.context_events import (
ContextEvent,
ContextEventType,
ContextListSink,
ContextTraceEmitter,
_run_scope,
copy_context_to_callable,
get_context_emitter,
trace_context,
)
@pytest.fixture
def recorder():
"""A live emitter installed for the test; yields its sink."""
sink = ContextListSink()
emitter = ContextTraceEmitter(sink=sink, session_id="replay-test", enabled=True)
with trace_context(emitter):
yield sink
def _types(sink):
return [e.event_type.value for e in sink.get_events()]
def _of(sink, event_type):
return [e for e in sink.get_events() if e.event_type.value == event_type]
def _by_seq(sink):
return {e.sequence_num: e for e in sink.get_events()}
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestSchema:
def test_schema_version_is_1_1(self):
from praisonaiagents.trace.context_events import CONTEXT_SCHEMA_VERSION
assert CONTEXT_SCHEMA_VERSION == "1.1"
def test_new_fields_round_trip(self):
event = ContextEvent(
event_type=ContextEventType.TASK_START,
timestamp=1.5,
session_id="s",
run_id="run-1",
parent_seq=3,
node_id="task:research",
)
data = event.to_dict()
assert data["run_id"] == "run-1"
assert data["parent_seq"] == 3
assert data["node_id"] == "task:research"
back = ContextEvent.from_dict(data)
assert (back.run_id, back.parent_seq, back.node_id) == ("run-1", 3, "task:research")
assert back.event_type == ContextEventType.TASK_START
def test_new_fields_are_omitted_when_none(self):
data = ContextEvent(ContextEventType.SESSION_START, 1.0, "s").to_dict()
for key in ("run_id", "parent_seq", "node_id", "branch_id"):
assert key not in data
def test_a_1_0_event_still_loads(self):
legacy = {
"schema_version": "1.0",
"event_type": "agent_start",
"timestamp": 10.0,
"session_id": "old",
"agent_name": "Writer",
"sequence_num": 4,
"messages_count": 0,
"tokens_used": 0,
"tokens_budget": 0,
"data": {"role": "w"},
"prompt_tokens": 0,
"completion_tokens": 0,
"cost_usd": 0.0,
}
event = ContextEvent.from_dict(legacy)
assert event.event_type == ContextEventType.AGENT_START
assert event.sequence_num == 4
assert event.run_id is None and event.parent_seq is None and event.node_id is None
def test_an_unknown_event_type_keeps_its_raw_value(self):
event = ContextEvent.from_dict({"event_type": "hologram_beamed", "session_id": "s"})
assert event.event_type == "hologram_beamed"
assert event.event_type.value == "hologram_beamed" # Enum-style callers still work
assert event.to_dict()["event_type"] == "hologram_beamed"
def test_control_a_known_event_type_is_still_the_enum(self):
event = ContextEvent.from_dict({"event_type": "tool_call_end", "session_id": "s"})
assert event.event_type is ContextEventType.TOOL_CALL_END
def test_new_event_types_have_contract_values(self):
assert {t.value for t in ContextEventType} >= {
"workflow_start", "workflow_end", "task_start", "task_end",
"step_start", "step_end", "approval_requested", "approval_decided",
}
# ---------------------------------------------------------------------------
# Spans, sequence numbers, branches, run ids
# ---------------------------------------------------------------------------
class TestSpans:
def test_parent_seq_nests_and_end_points_at_its_start(self, recorder):
emitter = get_context_emitter()
emitter.workflow_start("team", "t", "sequential") # 0
emitter.task_start("research", node_id="task:research") # 1
emitter.agent_start("Writer") # 2
emitter.message_added("Writer", "user", "hi", 1, 1) # 3 point
emitter.agent_end("Writer") # 4
emitter.task_end("research", "completed", node_id="task:research") # 5
emitter.workflow_end() # 6
e = _by_seq(recorder)
assert e[0].parent_seq is None
assert e[1].parent_seq == 0
assert e[2].parent_seq == 1
assert e[3].parent_seq == 2
assert e[4].parent_seq == 2
assert e[5].parent_seq == 1
assert e[6].parent_seq == 0
def test_a_start_left_open_is_closed_by_its_parent_end(self, recorder):
emitter = get_context_emitter()
emitter.workflow_start("team", "t", "sequential")
emitter.tool_call_start("Writer", "search") # never ended (exception path)
emitter.workflow_end()
emitter.message_added("x", "user", "after", 1, 1)
assert recorder.get_events()[-1].parent_seq is None
def test_spans_of_another_emitter_do_not_leak(self, recorder):
other_sink = ContextListSink()
other = ContextTraceEmitter(sink=other_sink, session_id="other")
get_context_emitter().workflow_start("team", "t", "sequential")
other.message_added("x", "user", "hi", 1, 1)
assert other_sink.get_events()[0].parent_seq is None
def test_run_scope_stamps_run_id_and_nested_scopes_inherit(self, recorder):
emitter = get_context_emitter()
with _run_scope("outer") as outer:
with _run_scope("inner") as inner:
emitter.message_added("a", "user", "x", 1, 1)
emitter.message_added("a", "user", "y", 1, 1)
assert outer == inner == "outer"
first, second = recorder.get_events()
assert first.run_id == "outer"
assert second.run_id is None
def test_eight_concurrent_branches_keep_their_own_branch_and_parent(self, recorder):
import concurrent.futures
emitter = get_context_emitter()
emitter.step_start("parallel", pattern="parallel", node_id="step:parallel")
fork_seq = recorder.get_events()[-1].sequence_num
def branch(idx):
trace = get_context_emitter()
trace.set_branch(f"parallel_{idx}")
try:
for _ in range(20):
trace.step_start(f"b{idx}", node_id=f"step:b{idx}")
time.sleep(0.0005)
trace.step_end(f"b{idx}", "completed", node_id=f"step:b{idx}")
finally:
trace.clear_branch()
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(copy_context_to_callable(lambda i=i: branch(i))) for i in range(8)]
for future in futures:
future.result()
emitter.step_end("parallel", "completed", node_id="step:parallel")
events = recorder.get_events()
seqs = [e.sequence_num for e in events]
assert seqs == sorted(seqs) == list(range(len(events))) # unique, append order
by_seq = {e.sequence_num: e for e in events}
for e in events[1:-1]:
idx = int(e.node_id.split(":b")[1])
assert e.branch_id == f"parallel_{idx}"
if e.event_type == ContextEventType.STEP_START:
assert e.parent_seq == fork_seq
else:
start = by_seq[e.parent_seq]
assert start.node_id == e.node_id and start.branch_id == e.branch_id
assert events[-1].parent_seq == fork_seq
assert events[-1].branch_id is None
# ---------------------------------------------------------------------------
# AgentFlow steps
# ---------------------------------------------------------------------------
def _handler(name, output=None):
def run(ctx):
return output if output is not None else name
run.__name__ = name
return run
class TestFlowSteps:
def test_flow_records_workflow_and_step_events_with_graph(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(name="demo", steps=[_handler("draft"), _handler("polish")])
flow.run("go")
types_ = _types(recorder)
assert types_[0] == "workflow_start" and types_[-1] == "workflow_end"
start = recorder.get_events()[0]
assert start.data["kind"] == "flow" and start.data["name"] == "demo"
assert start.data["graph"]["schema"] == "praisonai.graph/1"
steps = _of(recorder, "step_start")
assert [s.node_id for s in steps] == ["step:draft", "step:polish"]
assert all(s.parent_seq == start.sequence_num for s in steps)
run_ids = {e.run_id for e in recorder.get_events()}
assert len(run_ids) == 1 and None not in run_ids
assert [e.data["status"] for e in _of(recorder, "step_end")] == ["completed", "completed"]
assert recorder.get_events()[-1].data["status"] == "completed"
def test_parallel_with_eight_branches(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow, Parallel
def slow(name):
def run(ctx):
time.sleep(0.002)
return name
run.__name__ = name
return run
flow = AgentFlow(steps=[Parallel(steps=[slow(f"b{i}") for i in range(8)])])
flow.run("go")
events = recorder.get_events()
seqs = [e.sequence_num for e in events]
assert seqs == list(range(len(events)))
parallel = [e for e in _of(recorder, "step_start") if e.data.get("pattern") == "parallel"]
assert len(parallel) == 1 and parallel[0].node_id == "step:parallel"
branches = [e for e in _of(recorder, "step_start") if e.node_id != "step:parallel"]
assert sorted(e.branch_id for e in branches) == sorted(f"parallel_{i}" for i in range(8))
assert {e.parent_seq for e in branches} == {parallel[0].sequence_num}
by_seq = _by_seq(recorder)
for end in _of(recorder, "step_end"):
assert by_seq[end.parent_seq].node_id == end.node_id
assert {e.node_id for e in branches} == {f"step:b{i}" for i in range(8)}
def test_route_and_if_record_the_branch_taken(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow, If, Route
flow = AgentFlow(steps=[
_handler("classify", "this is urgent"),
Route(routes={"urgent": [_handler("page")], "default": [_handler("queue")]}),
If(condition="{{missing}} == yes", then_steps=[_handler("a")], else_steps=[_handler("b")]),
])
flow.run("go")
ends = {e.node_id: e for e in _of(recorder, "step_end")}
assert ends["step:route"].data["route"] == "urgent"
assert ends["step:if"].data["route"] == "false"
assert "step:page" in ends and "step:queue" not in ends
assert "step:b" in ends and "step:a" not in ends
def test_skipped_failed_and_cached_steps(self, recorder):
from praisonaiagents.task.task import Task
from praisonaiagents.workflows.workflows import AgentFlow
def boom(ctx):
raise RuntimeError("nope")
skip = Task(name="gated", handler=_handler("gated"), should_run=lambda ctx: False)
fail = Task(name="boom", handler=boom, max_retries=0, on_error="continue")
flow = AgentFlow(steps=[_handler("cached_step"), skip, fail], cache=True)
flow.run("go")
statuses = {e.node_id: e.data["status"] for e in _of(recorder, "step_end")}
assert statuses == {"step:cached_step": "completed", "step:gated": "skipped", "step:boom": "failed"}
recorder.clear()
flow.run("go")
statuses = {e.node_id: e.data["status"] for e in _of(recorder, "step_end")}
assert statuses["step:cached_step"] == "cached"
def test_nested_flow_inherits_the_outer_run_id(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow
inner = AgentFlow(name="inner", steps=[_handler("fetch")])
def nested(ctx):
return inner.run("sub")["output"]
outer = AgentFlow(name="outer", steps=[_handler("first"), nested])
outer.run("go")
starts = _of(recorder, "workflow_start")
assert [s.data["name"] for s in starts] == ["outer", "inner"]
assert len({e.run_id for e in recorder.get_events()}) == 1
step = [e for e in _of(recorder, "step_start") if e.node_id == "step:nested"][0]
assert starts[1].parent_seq == step.sequence_num
def test_team_inside_a_flow_inherits_the_flow_run_id(self, recorder, monkeypatch):
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
from praisonaiagents.workflows.workflows import AgentFlow
monkeypatch.setattr(AgentTeam, "execute_task", _fake_execute({}))
writer = Agent(name="Writer", instructions="write")
team = _team([Task(name="draft", description="d", agent=writer)], agents=[writer])
def run_team(ctx):
return team.start()
AgentFlow(name="outer", steps=[run_team]).run("go")
run_ids = {e.run_id for e in recorder.get_events()}
assert len(run_ids) == 1 and team.run_id not in run_ids
# ---------------------------------------------------------------------------
# AgentTeam tasks, manager decisions, human review
# ---------------------------------------------------------------------------
def _team(tasks, process="sequential", agents=None):
from praisonaiagents import Agent
from praisonaiagents.agents.agents import AgentTeam
agents = agents or [Agent(name="Writer", instructions="write")]
return AgentTeam(agents=agents, tasks=tasks, process=process, output="silent")
def _fake_execute(outputs):
"""Stand-in for AgentTeam.execute_task: no model, scripted results."""
from praisonaiagents.main import TaskOutput
def execute(self, task_id):
task = self.tasks[task_id]
raw = outputs.get(task.name, "done")
if raw is None:
return None
out = TaskOutput(description=task.description, raw=raw, agent=task.agent.name)
task.result = out
return out
return execute
class TestTeamTasks:
def test_task_completed_skipped_and_failed(self, recorder, monkeypatch):
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
writer = Agent(name="Writer", instructions="write")
done = Task(name="draft", description="Draft it", agent=writer)
skipped = Task(name="optional", description="Maybe", agent=writer, should_run=lambda ctx: False)
failed = Task(name="flaky", description="Never finishes", agent=writer, max_retries=1, retry_delay=0)
monkeypatch.setattr(AgentTeam, "execute_task", _fake_execute({"flaky": None}))
team = _team([done, skipped, failed], agents=[writer])
team.start()
ends = {e.node_id: e for e in _of(recorder, "task_end")}
assert ends["task:draft"].data["status"] == "completed"
assert ends["task:draft"].data["output_preview"] == "done"
assert ends["task:optional"].data["status"] == "skipped"
assert ends["task:flaky"].data["status"] == "failed"
starts = {e.node_id: e for e in _of(recorder, "task_start")}
assert starts["task:draft"].data["description_preview"] == "Draft it"
assert starts["task:draft"].data["agent"] == "Writer"
for node_id, end in ends.items():
assert end.parent_seq == starts[node_id].sequence_num
wf_start = _of(recorder, "workflow_start")[0]
assert wf_start.data["kind"] == "team" and wf_start.data["graph"]["kind"] == "team"
assert {s.parent_seq for s in starts.values()} == {wf_start.sequence_num}
assert _of(recorder, "workflow_end")[0].data["status"] == "failed"
assert {e.run_id for e in recorder.get_events()} == {team.run_id}
def test_async_team_records_tasks(self, recorder, monkeypatch):
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
async def aexecute(self, task_id):
return _fake_execute({})(self, task_id)
writer = Agent(name="Writer", instructions="write")
task = Task(name="draft", description="Draft", agent=writer, async_execution=True)
monkeypatch.setattr(AgentTeam, "aexecute_task", aexecute)
team = _team([task], agents=[writer])
asyncio.run(team.astart())
assert [e.data["status"] for e in _of(recorder, "task_end")] == ["completed"]
assert _types(recorder)[0] == "workflow_start"
assert _types(recorder)[-1] == "workflow_end"
def test_manager_choice_is_a_delegate_handoff(self, recorder, monkeypatch):
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
from praisonaiagents.process.manager_schema import ManagerInstructions
from praisonaiagents.process.process import Process
writer = Agent(name="Writer", instructions="write")
editor = Agent(name="Editor", instructions="edit")
t1 = Task(name="draft", description="Draft", agent=writer)
t2 = Task(name="review", description="Review", agent=writer)
script = iter([
ManagerInstructions(task_id=0, agent_name="Writer", action="execute"),
ManagerInstructions(task_id=1, agent_name="Editor", action="execute"),
])
monkeypatch.setattr(
Process, "_get_manager_instructions_with_fallback",
lambda self, *a: next(script),
)
monkeypatch.setattr(AgentTeam, "execute_task", _fake_execute({}))
team = _team([t1, t2], process="hierarchical", agents=[writer, editor])
team.start()
delegates = [e for e in _of(recorder, "agent_handoff") if e.data["kind"] == "delegate"]
assert [(d.agent_name, d.data["to_agent"], d.node_id) for d in delegates] == [
("Manager", "Writer", "task:draft"),
("Manager", "Editor", "task:review"),
]
summary = delegates[1].data["context_passed"]["task_status"]
assert [(s["name"], s["status"]) for s in summary] == [("draft", "completed"), ("review", "not started")]
manager = [e for e in _of(recorder, "task_start") if e.node_id == "manager:Manager"]
assert len(manager) == 1
def test_human_review_records_an_approval_pair(self, recorder):
from praisonaiagents import Agent, Task
from praisonaiagents.approval import ApprovalDecision, get_approval_registry
from praisonaiagents.main import TaskOutput
class Reviewer:
def request_approval_sync(self, request):
return ApprovalDecision(approved=True, reason="looks good", approver="ana")
registry = get_approval_registry()
previous = registry.get_backend()
registry.set_backend(Reviewer())
try:
writer = Agent(name="Writer", instructions="write")
task = Task(name="draft", description="Draft", agent=writer, human_input=True)
team = _team([task], agents=[writer])
out = TaskOutput(description="Draft", raw="api_key=sk-secret123456 text", agent="Writer")
team._apply_human_review(task, 0, out)
finally:
registry.set_backend(previous)
requested, decided = _of(recorder, "approval_requested"), _of(recorder, "approval_decided")
assert len(requested) == len(decided) == 1
assert requested[0].data["kind"] == "task_output_review"
assert requested[0].node_id == "task:draft"
assert "sk-secret123456" not in str(requested[0].data["args"])
assert decided[0].parent_seq == requested[0].sequence_num
assert decided[0].data["approved"] is True and decided[0].data["approver"] == "ana"
assert decided[0].data["approval_id"] == requested[0].data["approval_id"]
# ---------------------------------------------------------------------------
# Tool-call approvals
# ---------------------------------------------------------------------------
class TestToolApprovals:
def _registry(self, backend):
from praisonaiagents.approval.registry import ApprovalRegistry
registry = ApprovalRegistry()
registry.set_backend(backend)
registry.add_requirement("delete_file", risk_level="high")
return registry
def test_sync_approval_records_request_and_decision(self, recorder, monkeypatch):
from praisonaiagents.approval import ApprovalDecision
monkeypatch.delenv("PRAISONAI_AUTO_APPROVE", raising=False)
class Deny:
def request_approval_sync(self, request):
return ApprovalDecision(approved=False, reason="not today", approver="ops")
registry = self._registry(Deny())
decision = registry.approve_sync("Writer", "delete_file", {"path": "/x", "password": "hunter2"})
assert decision.approved is False
requested, decided = _of(recorder, "approval_requested"), _of(recorder, "approval_decided")
assert len(requested) == len(decided) == 1
assert requested[0].data["tool"] == "delete_file"
assert requested[0].data["risk"] == "high"
assert requested[0].data["kind"] == "tool_call"
assert requested[0].node_id == "tool:Writer/delete_file"
assert "hunter2" not in str(requested[0].data["args"])
assert decided[0].parent_seq == requested[0].sequence_num
assert decided[0].data == {
"approval_id": requested[0].data["approval_id"],
"approved": False, "reason": "not today", "approver": "ops", "modified": False,
}
def test_async_approval_records_request_and_decision(self, recorder, monkeypatch):
from praisonaiagents.approval import ApprovalDecision
monkeypatch.delenv("PRAISONAI_AUTO_APPROVE", raising=False)
class Allow:
async def request_approval(self, request):
return ApprovalDecision(approved=True, reason="ok", modified_args={"path": "/y"})
registry = self._registry(Allow())
asyncio.run(registry.approve_async("Writer", "delete_file", {"path": "/x"}))
decided = _of(recorder, "approval_decided")
assert len(decided) == 1 and decided[0].data["modified"] is True
assert decided[0].parent_seq == _of(recorder, "approval_requested")[0].sequence_num
def test_control_the_fast_path_records_nothing(self, recorder):
from praisonaiagents.approval.registry import ApprovalRegistry
ApprovalRegistry().approve_sync("Writer", "read_file", {}) # not required
assert _of(recorder, "approval_requested") == []
# ---------------------------------------------------------------------------
# Hand-offs
# ---------------------------------------------------------------------------
class TestHandoffs:
def _pair(self):
from praisonaiagents import Agent
source = Agent(name="Router", instructions="route")
target = Agent(name="Billing", instructions="bill")
target.chat = lambda *a, **k: "handled"
async def achat(*a, **k):
return "handled"
target.achat = achat
return source, target
def _one_handoff(self, sink):
handoffs = _of(sink, "agent_handoff")
assert len(handoffs) == 1, [h.data for h in handoffs]
h = handoffs[0]
assert (h.agent_name, h.data["from_agent"], h.data["to_agent"]) == ("Router", "Router", "Billing")
assert h.data["kind"] == "handoff"
assert h.node_id == "agent:Billing"
return h
def test_programmatic(self, recorder):
from praisonaiagents.agent.handoff import Handoff
source, target = self._pair()
Handoff(agent=target).execute_programmatic(source, "refund order 7")
assert self._one_handoff(recorder).data["reason"] == "refund order 7"
def test_async(self, recorder):
from praisonaiagents.agent.handoff import Handoff
source, target = self._pair()
asyncio.run(Handoff(agent=target).execute_async(source, "refund order 7"))
self._one_handoff(recorder)
def test_tool_function(self, recorder):
from praisonaiagents.agent.handoff import Handoff
source, target = self._pair()
tool = Handoff(agent=target).to_tool_function(source)
tool(task="refund order 7")
h = self._one_handoff(recorder)
assert h.data["context_passed"]["args"] == {"task": "refund order 7"}
def test_typed_programmatic_and_async(self, recorder):
from pydantic import BaseModel
from praisonaiagents.agent.handoff import TypedHandoff
class Refund(BaseModel):
order: int
source, target = self._pair()
TypedHandoff(agent=target, input_schema=Refund).execute_programmatic(source, Refund(order=7))
self._one_handoff(recorder)
recorder.clear()
asyncio.run(TypedHandoff(agent=target, input_schema=Refund).execute_async(source, Refund(order=7)))
self._one_handoff(recorder)
def test_control_a_refused_handoff_records_nothing(self, recorder):
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, _clear_handoff_chain, _push_handoff
source, target = self._pair()
_push_handoff("Billing") # Billing already in the chain -> cycle
try:
with pytest.raises(Exception):
Handoff(agent=target, config=HandoffConfig(detect_cycles=True)).execute_programmatic(source, "x")
finally:
_clear_handoff_chain()
assert _of(recorder, "agent_handoff") == []
# ---------------------------------------------------------------------------
# LiteLLM path
# ---------------------------------------------------------------------------
def _response(text="hello", prompt=11, completion=3):
message = types.SimpleNamespace(content=text, tool_calls=None)
choice = types.SimpleNamespace(message=message, finish_reason="stop", delta=None)
usage = types.SimpleNamespace(prompt_tokens=prompt, completion_tokens=completion, total_tokens=prompt + completion)
return types.SimpleNamespace(choices=[choice], usage=usage, model="gpt-4o-mini")
def _chunk(text=None, finish=None, usage=None):
delta = types.SimpleNamespace(content=text, tool_calls=None)
choice = types.SimpleNamespace(delta=delta, finish_reason=finish)
return types.SimpleNamespace(choices=[choice], usage=usage)
@pytest.fixture
def fake_litellm(monkeypatch):
import litellm
import praisonaiagents.llm.llm as llm_module
monkeypatch.setattr(llm_module, "check_model_request", lambda *a, **k: None)
calls = []
def completion(**params):
calls.append(params)
if params.get("stream"):
usage = types.SimpleNamespace(prompt_tokens=5, completion_tokens=2)
return iter([_chunk("hel"), _chunk("lo", finish="stop"), _chunk(usage=usage)])
return _response()
async def acompletion(**params):
calls.append(params)
if params.get("stream"):
async def gen():
for c in [_chunk("a"), _chunk("b", finish="stop")]:
yield c
return gen()
return _response("async")
monkeypatch.setattr(litellm, "completion", completion)
monkeypatch.setattr(litellm, "acompletion", acompletion)
return calls
class TestLiteLLMPath:
def _llm(self):
from praisonaiagents.llm.llm import LLM
return LLM(model="gpt-4o-mini")
def test_non_streaming_call_records_request_and_response(self, recorder, fake_litellm):
llm = self._llm()
emitter = get_context_emitter()
emitter.agent_start("Writer")
llm._completion_with_retry(model="gpt-4o-mini", messages=[{"role": "user", "content": "hi"}])
emitter.agent_end("Writer")
request, response = _of(recorder, "llm_request"), _of(recorder, "llm_response")
assert len(request) == len(response) == 1
assert request[0].agent_name == "Writer" and request[0].node_id == "agent:Writer"
assert request[0].messages_count == 1
r = response[0]
assert (r.prompt_tokens, r.completion_tokens) == (11, 3)
assert r.data["model"] == "gpt-4o-mini"
assert r.data["response_content"] == "hello"
assert r.data["finish_reason"] == "stop"
assert r.data["duration_ms"] >= 0
assert r.cost_usd > 0 # litellm knows gpt-4o-mini's price
def test_response_records_the_serving_model_and_a_message_snapshot(self, recorder, fake_litellm, monkeypatch):
import litellm
served = _response()
served.model = "gpt-4o" # a failover profile answered
monkeypatch.setattr(litellm, "completion", lambda **params: served)
messages = [{"role": "user", "content": "hi"}]
self._llm()._completion_with_retry(model="gpt-4o-mini", messages=messages)
messages.append({"role": "assistant", "content": "later turn"})
assert _of(recorder, "llm_response")[0].data["model"] == "gpt-4o"
assert _of(recorder, "llm_request")[0].messages_count == 1
assert len(_of(recorder, "llm_request")[0].data["messages"]) == 1
def test_streaming_call_records_response_when_the_stream_ends(self, recorder, fake_litellm):
llm = self._llm()
stream = llm._completion_with_retry(model="gpt-4o-mini", messages=[], stream=True)
assert _of(recorder, "llm_response") == []
text = "".join(c.choices[0].delta.content or "" for c in stream)
assert text == "hello"
response = _of(recorder, "llm_response")
assert len(response) == 1
assert response[0].data["response_content"] == "hello"
assert (response[0].prompt_tokens, response[0].completion_tokens) == (5, 2)
def test_async_calls_record_events(self, recorder, fake_litellm):
llm = self._llm()
async def run():
await llm._acompletion_with_retry(model="gpt-4o-mini", messages=[])
stream = await llm._acompletion_with_retry(model="gpt-4o-mini", messages=[], stream=True)
return "".join([c.choices[0].delta.content async for c in stream])
assert asyncio.run(run()) == "ab"
responses = _of(recorder, "llm_response")
assert [r.data["response_content"] for r in responses] == ["async", "ab"]
assert len(_of(recorder, "llm_request")) == 2
def test_a_call_already_recorded_by_the_agent_is_not_counted_twice(self, recorder, fake_litellm):
llm = self._llm()
emitter = get_context_emitter()
emitter.agent_start("Writer")
emitter.llm_request("Writer", messages_count=1, tokens_used=0, model="gpt-4o-mini")
llm._completion_with_retry(model="gpt-4o-mini", messages=[])
emitter.llm_response("Writer", duration_ms=1.0)
llm._completion_with_retry(model="gpt-4o-mini", messages=[]) # a fresh call counts
emitter.agent_end("Writer")
assert len(_of(recorder, "llm_request")) == 2
assert len(_of(recorder, "llm_response")) == 2
def test_a_failed_call_records_an_error_response(self, recorder, monkeypatch):
import litellm
import praisonaiagents.llm.llm as llm_module
monkeypatch.setattr(llm_module, "check_model_request", lambda *a, **k: None)
def explode(**params):
raise ValueError("bad request")
monkeypatch.setattr(litellm, "completion", explode)
llm = self._llm()
llm._max_retries = 0
with pytest.raises(Exception):
llm._completion_with_retry(model="gpt-4o-mini", messages=[])
response = _of(recorder, "llm_response")
assert len(response) == 1 and response[0].data["finish_reason"] == "error"
def test_responses_api_calls_record_events(self, recorder, monkeypatch):
"""OpenAI models on the LiteLLM path go through the Responses API."""
from praisonaiagents.llm.llm import LLM
usage = types.SimpleNamespace(input_tokens=7, output_tokens=2)
monkeypatch.setattr(LLM, "_call_responses_api_impl", lambda self, **p: types.SimpleNamespace(usage=usage))
monkeypatch.setattr(LLM, "_extract_from_responses_output", lambda self, r: ("answer", None, None))
monkeypatch.setattr(LLM, "_stream_responses_api_impl",
lambda self, params, *a, **k: ("streamed", None, types.SimpleNamespace(usage=usage)))
async def acall(self, **p):
return types.SimpleNamespace(usage=usage)
monkeypatch.setattr(LLM, "_call_responses_api_async_impl", acall)
llm = self._llm()
params = {"model": "gpt-4o-mini", "input": [{"role": "user", "content": "hi"}]}
llm._call_responses_api(**params)
llm._stream_responses_api(dict(params), verbose=False)
asyncio.run(llm._call_responses_api_async(**params))
requests, responses = _of(recorder, "llm_request"), _of(recorder, "llm_response")
assert len(requests) == len(responses) == 3
assert requests[0].messages_count == 1
assert [r.data["response_content"] for r in responses] == ["answer", "streamed", "answer"]
assert all((r.prompt_tokens, r.completion_tokens) == (7, 2) for r in responses)
def test_control_disabled_tracing_leaves_the_stream_untouched(self, fake_litellm):
llm = self._llm()
stream = llm._completion_with_retry(model="gpt-4o-mini", messages=[], stream=True)
assert type(stream).__name__ == "list_iterator"
# ---------------------------------------------------------------------------
# The disabled emitter costs (almost) nothing
# ---------------------------------------------------------------------------
def test_disabled_emitter_adds_next_to_nothing():
emitter = ContextTraceEmitter(enabled=False)
n = 20000
def baseline():
pass
t0 = time.perf_counter()
for _ in range(n):
baseline()
base = time.perf_counter() - t0
t0 = time.perf_counter()
for _ in range(n):
emitter.task_start("t", "d", "a", "task:t")
emitter.step_end("s", "completed", 1.0, "out")
emitter.agent_handoff("a", "b", kind="delegate")
emitter.approval_requested("id", "tool", {"x": 1})
emitter.workflow_start("team", "t", "sequential", graph={})
cost = time.perf_counter() - t0
# Five disabled calls per iteration: one attribute check each.
assert cost / (n * 5) < 2e-6, f"{cost / (n * 5) * 1e9:.0f} ns per disabled call"
assert cost < base * 5 * 20 + 0.05
def test_disabled_tracing_never_builds_the_graph():
from praisonaiagents.trace.context_events import _workflow_scope
built = []
with _workflow_scope("team", "t", "sequential", graph_fn=lambda: built.append(1)) as run:
assert run is None
assert built == []
class TestTeamRunLabel:
"""An unnamed team is labelled by its agents, not by whichever agent comes first."""
def test_unnamed_team_is_labelled_by_its_agents(self):
from praisonaiagents import Agent, AgentTeam
team = AgentTeam(agents=[Agent(name="Researcher", output="silent"), Agent(name="Writer", output="silent")])
assert team._replay_name() == "Researcher + Writer"
def test_named_team_keeps_its_name(self):
from praisonaiagents import Agent, AgentTeam
team = AgentTeam(agents=[Agent(name="Researcher", output="silent")], name="EV brief")
assert team._replay_name() == "EV brief"
class TestStepVariables:
"""step_end records the variables a step set, so a re-run can restore them."""
def test_step_end_records_handler_variables_redacted(self, recorder):
from praisonaiagents.workflows import StepResult
from praisonaiagents.workflows.workflows import AgentFlow
def research(ctx):
return StepResult(output="notes", variables={"customer_id": 42, "api_key": "sk-abcdefghijklmnop1234"})
def draft(ctx):
return StepResult(output="draft")
AgentFlow(name="v", steps=[research, draft]).run("go")
ends = {e.node_id: e.data for e in _of(recorder, "step_end")}
assert ends["step:research"]["variables"]["customer_id"] == 42
assert "sk-abcdefghijklmnop1234" not in str(ends["step:research"]["variables"])
assert "variables" not in ends["step:draft"]
def test_nested_step_end_records_handler_variables(self, recorder):
"""Steps run through _execute_single_step_internal (patterns, hierarchical) record them too."""
from praisonaiagents.workflows import StepResult
from praisonaiagents.workflows.workflows import AgentFlow, If
def research(ctx):
return StepResult(output="notes", variables={"customer_id": 42})
AgentFlow(name="v", steps=[If(condition="1 > 0", then_steps=[research])]).run("go")
ends = {e.node_id: e.data for e in _of(recorder, "step_end")}
variables = next(d["variables"] for n, d in ends.items() if n.endswith("research"))
assert variables == {"customer_id": 42}
def test_cache_hit_step_end_leaves_out_the_output_variable(self, recorder):
"""The step cache stores the output variable (the full output); a cached
step_end must trace only what the handler set, like the cold run."""
from praisonaiagents.workflows import StepResult
from praisonaiagents.workflows.workflows import AgentFlow
def research(ctx):
return StepResult(output="the full step output " * 50, variables={"customer_id": 42})
flow = AgentFlow(name="v", steps=[research], cache=True)
flow.run("go")
flow.run("go") # cache hit
ends = [e.data for e in _of(recorder, "step_end")]
assert [d["status"] for d in ends] == ["completed", "cached"]
for d in ends:
assert d["variables"] == {"customer_id": 42}
assert "the full step output" not in str(d["variables"])
def test_nested_step_records_a_variable_changed_in_place(self, recorder):
"""A list the step appends to (same object) still counts as set by it."""
from praisonaiagents.workflows import StepResult
from praisonaiagents.workflows.workflows import AgentFlow, If
def seed(ctx):
return StepResult(output="s", variables={"items": ["a"]})
def add(ctx):
ctx.variables["items"].append("b")
return StepResult(output="m")
AgentFlow(name="v", steps=[seed, If(condition="1 > 0", then_steps=[add])]).run("go")
ends = {e.node_id: e.data for e in _of(recorder, "step_end")}
variables = next(d.get("variables") for n, d in ends.items() if n.endswith("add"))
assert variables == {"items": ["a", "b"]}
def test_uncopyable_variable_does_not_hide_an_in_place_change(self, recorder):
"""One variable that cannot be deep-copied must not stop the others being compared by value."""
import threading
from praisonaiagents.workflows import StepResult
from praisonaiagents.workflows.workflows import AgentFlow, If
def seed(ctx):
return StepResult(output="s", variables={"items": ["a"]})
def add(ctx):
ctx.variables["items"].append("b")
return StepResult(output="m")
flow = AgentFlow(name="v", steps=[seed, If(condition="1 > 0", then_steps=[add])],
variables={"lock": threading.Lock()})
flow.run("go")
ends = {e.node_id: e.data for e in _of(recorder, "step_end")}
variables = next(d.get("variables") for n, d in ends.items() if n.endswith("add"))
assert variables == {"items": ["a", "b"]}
def test_skipped_or_cached_nested_step_does_not_copy_the_scope(self, recorder, monkeypatch):
"""Only a nested step that really runs snapshots the variables; a cache
hit still records what the handler set."""
from praisonaiagents.workflows import StepResult, Task
from praisonaiagents.workflows import workflows as wf
copies = []
real = wf._isolate_scope
monkeypatch.setattr(wf, "_isolate_scope", lambda v: copies.append(1) or real(v))
def research(ctx):
return StepResult(output="notes", variables={"customer_id": 42})
def never(ctx):
return StepResult(output="x")
steps = [wf.If(condition="1 > 0", then_steps=[
research, Task(name="never", handler=never, should_run=lambda ctx: False),
])]
flow = wf.AgentFlow(name="v", steps=steps, variables={"items": list(range(1000))}, cache=True)
flow.run("go")
assert len(copies) == 1 # research ran; the skipped step copied nothing
copies.clear()
flow.run("go") # research is a cache hit
assert copies == []
cached = [e.data for e in _of(recorder, "step_end") if e.data["status"] == "cached"]
assert [d.get("variables") for d in cached] == [{"customer_id": 42}]
@@ -1,260 +0,0 @@
"""Unnamed agents in traces and graphs, agent_end status, and async model-call events.
Deterministic: the model call is replaced, so nothing reaches a provider.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from praisonaiagents.trace.context_events import (
ContextListSink,
ContextTraceEmitter,
reset_context_emitter,
set_context_emitter,
)
@pytest.fixture
def sink(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
s = ContextListSink()
token = set_context_emitter(ContextTraceEmitter(sink=s, session_id="t", enabled=True))
yield s
reset_context_emitter(token)
def _of(sink, kind):
return [e for e in sink.get_events() if e.event_type.value == kind]
def _fake_completion(text="Solar sails ride sunlight."):
message = SimpleNamespace(content=text, tool_calls=None, role="assistant", reasoning_content=None)
return SimpleNamespace(
choices=[SimpleNamespace(message=message, finish_reason="stop", index=0)],
usage=SimpleNamespace(prompt_tokens=11, completion_tokens=7, total_tokens=18),
model="gpt-4o-mini",
)
def search(query: str) -> str:
"""Search the web."""
return "result"
# --- to_graph and ids ---------------------------------------------------------
def test_to_graph_accepts_an_unnamed_agent(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
from praisonaiagents import Agent
from praisonaiagents.workflows.diagram import to_graph
graph = to_graph(Agent(instructions="Answer briefly.", tools=[search]))
ids = [n["id"] for n in graph["nodes"]]
assert ids[:3] == ["start:begin", "agent:Agent", "tool:Agent/search"] or set(ids) >= {
"start:begin", "agent:Agent", "tool:Agent/search", "end:done"}
assert next(n for n in graph["nodes"] if n["id"] == "agent:Agent")["label"] == "Agent"
def test_named_agent_graph_is_unchanged(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
from praisonaiagents import Agent
from praisonaiagents.workflows.diagram import to_graph
ids = [n["id"] for n in to_graph(Agent(name="Writer", instructions="x", tools=[search]))["nodes"]]
assert "agent:Writer" in ids and "tool:Writer/search" in ids
def test_emitter_ids_for_an_unnamed_agent():
s = ContextListSink()
em = ContextTraceEmitter(sink=s, session_id="t", enabled=True)
em.agent_start(None)
em.tool_call_start(None, "search", {"query": "x"})
em.tool_call_end(None, "search", "ok", 1.0)
em.agent_end(None)
assert [e.node_id for e in s.get_events()] == [
"agent:Agent", "tool:Agent/search", "tool:Agent/search", "agent:Agent"]
def test_unnamed_agent_run_matches_its_graph(sink):
from praisonaiagents import Agent
from praisonaiagents.workflows.diagram import to_graph
agent = Agent(instructions="Answer briefly.")
with patch.object(agent, "_chat_completion", return_value=_fake_completion()):
agent.chat("Why do solar sails work?")
graph_ids = {n["id"] for n in to_graph(agent)["nodes"]}
for kind in ("agent_start", "agent_end"):
(event,) = _of(sink, kind)
assert event.agent_name is None # the name is not invented on the event
assert event.node_id == "agent:Agent" and event.node_id in graph_ids
# --- agent_end status ---------------------------------------------------------
def test_agent_end_records_completed(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x")
with patch.object(agent, "_chat_completion", return_value=_fake_completion()):
assert agent.chat("hi")
assert _of(sink, "agent_end")[0].data == {"status": "completed"}
def test_agent_end_records_failed(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x")
with patch.object(agent, "_chat_completion", side_effect=RuntimeError("provider down")):
try:
result = agent.chat("hi")
except Exception:
result = None
assert result is None
assert _of(sink, "agent_end")[0].data == {"status": "failed"}
# --- async model calls --------------------------------------------------------
def test_astart_records_the_model_call(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x", llm="gpt-4o-mini")
async def fake_dispatch(*args, **kwargs):
return _fake_completion()
with patch.object(agent, "_achat_completion_with_retry_dispatch", side_effect=fake_dispatch):
answer = asyncio.run(agent.astart("Why do solar sails work?"))
assert "sunlight" in str(answer)
(req,) = _of(sink, "llm_request")
(resp,) = _of(sink, "llm_response")
assert req.node_id == resp.node_id == "agent:Writer"
assert resp.prompt_tokens == 11 and resp.completion_tokens == 7 and resp.cost_usd > 0
assert "sunlight" in (resp.data.get("response_content") or "")
assert _of(sink, "agent_end")[0].data == {"status": "completed"}
def test_async_model_error_is_recorded(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x", llm="gpt-4o-mini")
async def boom(*args, **kwargs):
raise RuntimeError("rate limited")
with patch.object(agent, "_achat_completion_with_retry_dispatch", side_effect=boom):
try:
asyncio.run(agent.astart("hi"))
except Exception:
pass
responses = _of(sink, "llm_response")
assert responses and responses[0].data.get("finish_reason") == "error"
assert "rate limited" in (responses[0].data.get("response_content") or "")
def test_async_path_records_nothing_when_tracing_is_off(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
from praisonaiagents import Agent
s = ContextListSink()
token = set_context_emitter(ContextTraceEmitter(sink=s, session_id="t", enabled=False))
try:
agent = Agent(name="Writer", instructions="x", llm="gpt-4o-mini")
async def fake_dispatch(*args, **kwargs):
return _fake_completion()
with patch.object(agent, "_achat_completion_with_retry_dispatch", side_effect=fake_dispatch) as d:
asyncio.run(agent.astart("hi"))
assert d.called and s.get_events() == []
finally:
reset_context_emitter(token)
# --- streamed turns (start(stream=True)) ---------------------------------------
def _fake_stream(self, prompt, **kwargs):
yield "Solar "
yield "sails."
def test_streamed_turn_records_agent_start_and_end(sink):
from praisonaiagents import Agent
agent = Agent(instructions="x")
with patch.object(type(agent), "_start_stream_turn", _fake_stream):
text = "".join(agent._start_stream("hi"))
assert text == "Solar sails."
kinds = [(e.event_type.value, e.node_id, e.data.get("status")) for e in sink.get_events()]
assert kinds == [("agent_start", "agent:Agent", None), ("agent_end", "agent:Agent", "completed")]
def test_streamed_turn_stopped_early_is_cancelled(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x")
with patch.object(type(agent), "_start_stream_turn", _fake_stream):
gen = agent._start_stream("hi")
next(gen)
gen.close() # the page stopped reading
assert _of(sink, "agent_end")[0].data == {"status": "cancelled"}
def test_streamed_turn_that_raises_is_failed(sink):
from praisonaiagents import Agent
def boom(self, prompt, **kwargs):
yield "x"
raise RuntimeError("provider down")
agent = Agent(name="Writer", instructions="x")
with patch.object(type(agent), "_start_stream_turn", boom):
with pytest.raises(RuntimeError):
list(agent._start_stream("hi"))
assert _of(sink, "agent_end")[0].data == {"status": "failed"}
def test_streamed_turn_records_nothing_when_tracing_is_off(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
from praisonaiagents import Agent
s = ContextListSink()
token = set_context_emitter(ContextTraceEmitter(sink=s, session_id="t", enabled=False))
try:
agent = Agent(name="Writer", instructions="x")
with patch.object(type(agent), "_start_stream_turn", _fake_stream):
assert "".join(agent._start_stream("hi")) == "Solar sails."
assert s.get_events() == []
finally:
reset_context_emitter(token)
def test_streamed_turn_blocked_by_guardrail_is_failed(sink):
from praisonaiagents import Agent
agent = Agent(name="Writer", instructions="x")
with patch.object(agent, "_validate_input_with_guardrail", return_value=(False, None, "no")):
assert "blocked" in "".join(agent._start_stream("hi"))
assert _of(sink, "agent_end")[0].data == {"status": "failed"}
def test_streamed_turn_fallback_records_the_turn_once(sink):
from praisonaiagents import Agent
def falls_back(self, prompt, **kwargs):
yield self._stream_fallback_chat(prompt, kwargs, RuntimeError("stream broke"))
agent = Agent(name="Writer", instructions="x")
with patch.object(type(agent), "_start_stream_impl", falls_back), \
patch.object(agent, "_chat_completion", return_value=_fake_completion()):
assert "".join(agent._start_stream("hi")) == "Solar sails ride sunlight."
kinds = [e.event_type.value for e in sink.get_events() if e.event_type.value.startswith("agent_")]
assert kinds == ["agent_start", "agent_end"]
assert _of(sink, "agent_end")[0].data == {"status": "completed"}
# chat() outside a stream still records its own turn.
with patch.object(agent, "_chat_completion", return_value=_fake_completion()):
agent.chat("again")
assert len(_of(sink, "agent_start")) == 2
@@ -1,107 +0,0 @@
"""Definitions whose graphs are pinned as golden JSON (see test_graph.py).
Kept in their own module so a subprocess can rebuild them and prove the node
ids do not depend on the process (hash seed, uuids, object ids).
"""
def search_web(query: str) -> str:
"""Search the web."""
return query
def score(ctx):
return "approved"
def fetch(ctx):
return "a,b"
def tidy(ctx):
return "ok"
def team():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
researcher = Agent(name="Researcher", instructions="Find facts", tools=[search_web])
billing = Agent(name="Billing", instructions="Handle billing")
writer = Agent(name="Writer", instructions="Write", handoffs=[billing])
research = Task(name="research", description="Find facts", agent=researcher,
is_start=True, next_tasks=["draft"])
draft = Task(name="draft", description="Write the piece", agent=writer,
context=[research], human_input=True,
condition={"approved": ["publish"], "rejected": ["research"]})
publish = Task(name="publish", description="Ship it", agent=writer)
unnamed = Task(description="Archive everything afterwards", agent=writer)
return AgentTeam(agents=[researcher, writer], tasks=[research, draft, publish, unnamed],
process="workflow", name="newsroom")
def sequential_team():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
researcher = Agent(name="Researcher", instructions="Find facts", tools=[search_web])
writer = Agent(name="Writer", instructions="Write")
return AgentTeam(
agents=[researcher, writer],
tasks=[
Task(name="research", description="Find facts", agent=researcher),
Task(name="write", description="Write it up", agent=writer),
Task(name="write", description="Write it again", agent=writer),
],
name="pair",
)
def hierarchical_team():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
researcher = Agent(name="Researcher", instructions="Find facts", tools=[search_web])
writer = Agent(name="Writer", instructions="Write")
return AgentTeam(
agents=[researcher, writer],
tasks=[
Task(name="research", description="Find facts", agent=researcher),
Task(name="write", description="Write it up", agent=writer),
],
process="hierarchical",
manager_llm="gpt-4o-mini",
name="managed",
)
def flow():
from praisonaiagents import Agent
from praisonaiagents.workflows.workflows import (
AgentFlow, Discussion, If, Include, Loop, Parallel, Repeat, Route,
)
researcher = Agent(name="Researcher", instructions="Find facts", tools=[search_web])
critic = Agent(name="Critic", instructions="Critique")
return AgentFlow(
name="pipeline",
steps=[
researcher,
score,
Route(routes={"approved": [tidy], "default": [fetch]}),
Parallel(steps=[fetch, tidy]),
Loop(step=tidy, over="items"),
If(condition="{{score}} > 80", then_steps=[tidy], else_steps=[Repeat(fetch, max_iterations=2)]),
Discussion(agents=[researcher, critic], rounds=1),
researcher,
Include(recipe="publisher"),
],
)
BUILDERS = {
"team": team,
"sequential_team": sequential_team,
"hierarchical_team": hierarchical_team,
"flow": flow,
}
@@ -1,431 +0,0 @@
{
"edges": [
{
"id": "start:begin->step:Researcher:next",
"kind": "next",
"label": null,
"source": "start:begin",
"target": "step:Researcher"
},
{
"id": "agent:Researcher->tool:Researcher/search_web:uses",
"kind": "uses",
"label": null,
"source": "agent:Researcher",
"target": "tool:Researcher/search_web"
},
{
"id": "step:Researcher->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "step:Researcher",
"target": "agent:Researcher"
},
{
"id": "step:Researcher->step:score:next",
"kind": "next",
"label": null,
"source": "step:Researcher",
"target": "step:score"
},
{
"id": "step:score->decision:route:next",
"kind": "next",
"label": null,
"source": "step:score",
"target": "decision:route"
},
{
"id": "decision:route->step:tidy:routes",
"kind": "routes",
"label": "approved",
"source": "decision:route",
"target": "step:tidy"
},
{
"id": "decision:route->step:fetch:routes",
"kind": "routes",
"label": "default",
"source": "decision:route",
"target": "step:fetch"
},
{
"id": "step:tidy->join:route:next",
"kind": "next",
"label": null,
"source": "step:tidy",
"target": "join:route"
},
{
"id": "step:fetch->join:route:next",
"kind": "next",
"label": null,
"source": "step:fetch",
"target": "join:route"
},
{
"id": "join:route->fork:parallel:next",
"kind": "next",
"label": null,
"source": "join:route",
"target": "fork:parallel"
},
{
"id": "fork:parallel->step:fetch#2:parallel",
"kind": "parallel",
"label": null,
"source": "fork:parallel",
"target": "step:fetch#2"
},
{
"id": "fork:parallel->step:tidy#2:parallel",
"kind": "parallel",
"label": null,
"source": "fork:parallel",
"target": "step:tidy#2"
},
{
"id": "step:fetch#2->join:parallel:next",
"kind": "next",
"label": null,
"source": "step:fetch#2",
"target": "join:parallel"
},
{
"id": "step:tidy#2->join:parallel:next",
"kind": "next",
"label": null,
"source": "step:tidy#2",
"target": "join:parallel"
},
{
"id": "join:parallel->step:tidy#3:next",
"kind": "next",
"label": null,
"source": "join:parallel",
"target": "step:tidy#3"
},
{
"id": "step:tidy#3->step:tidy#3:loop",
"kind": "loop",
"label": "over items",
"source": "step:tidy#3",
"target": "step:tidy#3"
},
{
"id": "step:tidy#3->decision:if:next",
"kind": "next",
"label": null,
"source": "step:tidy#3",
"target": "decision:if"
},
{
"id": "decision:if->step:tidy#4:routes",
"kind": "routes",
"label": "true",
"source": "decision:if",
"target": "step:tidy#4"
},
{
"id": "step:fetch#3->step:fetch#3:loop",
"kind": "loop",
"label": "up to 2x",
"source": "step:fetch#3",
"target": "step:fetch#3"
},
{
"id": "decision:if->step:fetch#3:routes",
"kind": "routes",
"label": "false",
"source": "decision:if",
"target": "step:fetch#3"
},
{
"id": "step:tidy#4->join:if:next",
"kind": "next",
"label": null,
"source": "step:tidy#4",
"target": "join:if"
},
{
"id": "step:fetch#3->join:if:next",
"kind": "next",
"label": null,
"source": "step:fetch#3",
"target": "join:if"
},
{
"id": "join:if->step:discussion:next",
"kind": "next",
"label": null,
"source": "join:if",
"target": "step:discussion"
},
{
"id": "step:discussion->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "step:discussion",
"target": "agent:Researcher"
},
{
"id": "step:discussion->agent:Critic:assigned",
"kind": "assigned",
"label": null,
"source": "step:discussion",
"target": "agent:Critic"
},
{
"id": "step:discussion->step:Researcher#2:next",
"kind": "next",
"label": null,
"source": "step:discussion",
"target": "step:Researcher#2"
},
{
"id": "step:Researcher#2->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "step:Researcher#2",
"target": "agent:Researcher"
},
{
"id": "step:Researcher#2->step:include:next",
"kind": "next",
"label": null,
"source": "step:Researcher#2",
"target": "step:include"
},
{
"id": "step:include->end:done:next",
"kind": "next",
"label": null,
"source": "step:include",
"target": "end:done"
}
],
"kind": "flow",
"name": "pipeline",
"nodes": [
{
"id": "start:begin",
"kind": "start",
"label": "Brief",
"meta": {},
"parent": null
},
{
"id": "step:Researcher",
"kind": "step",
"label": "Researcher",
"meta": {},
"parent": null
},
{
"id": "agent:Researcher",
"kind": "agent",
"label": "Researcher",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "tool:Researcher/search_web",
"kind": "tool",
"label": "search_web",
"meta": {},
"parent": null
},
{
"id": "step:score",
"kind": "step",
"label": "score",
"meta": {},
"parent": null
},
{
"id": "step:route",
"kind": "step",
"label": "route",
"meta": {
"pattern": "route"
},
"parent": null
},
{
"id": "decision:route",
"kind": "decision",
"label": "route",
"meta": {},
"parent": "step:route"
},
{
"id": "step:tidy",
"kind": "step",
"label": "tidy",
"meta": {},
"parent": "step:route"
},
{
"id": "step:fetch",
"kind": "step",
"label": "fetch",
"meta": {},
"parent": "step:route"
},
{
"id": "join:route",
"kind": "join",
"label": "join",
"meta": {},
"parent": "step:route"
},
{
"id": "step:parallel",
"kind": "step",
"label": "parallel",
"meta": {
"pattern": "parallel"
},
"parent": null
},
{
"id": "fork:parallel",
"kind": "fork",
"label": "fork",
"meta": {},
"parent": "step:parallel"
},
{
"id": "step:fetch#2",
"kind": "step",
"label": "fetch",
"meta": {},
"parent": "step:parallel"
},
{
"id": "step:tidy#2",
"kind": "step",
"label": "tidy",
"meta": {},
"parent": "step:parallel"
},
{
"id": "join:parallel",
"kind": "join",
"label": "join",
"meta": {},
"parent": "step:parallel"
},
{
"id": "step:loop",
"kind": "step",
"label": "loop",
"meta": {
"over": "items",
"pattern": "loop"
},
"parent": null
},
{
"id": "step:tidy#3",
"kind": "step",
"label": "tidy",
"meta": {},
"parent": "step:loop"
},
{
"id": "step:if",
"kind": "step",
"label": "if",
"meta": {
"condition": "{{score}} > 80",
"pattern": "if"
},
"parent": null
},
{
"id": "decision:if",
"kind": "decision",
"label": "{{score}} > 80",
"meta": {},
"parent": "step:if"
},
{
"id": "step:tidy#4",
"kind": "step",
"label": "tidy",
"meta": {},
"parent": "step:if"
},
{
"id": "step:repeat",
"kind": "step",
"label": "repeat",
"meta": {
"max_iterations": 2,
"pattern": "repeat"
},
"parent": "step:if"
},
{
"id": "step:fetch#3",
"kind": "step",
"label": "fetch",
"meta": {},
"parent": "step:repeat"
},
{
"id": "join:if",
"kind": "join",
"label": "join",
"meta": {},
"parent": "step:if"
},
{
"id": "step:discussion",
"kind": "step",
"label": "discussion",
"meta": {
"pattern": "discussion"
},
"parent": null
},
{
"id": "agent:Critic",
"kind": "agent",
"label": "Critic",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "step:Researcher#2",
"kind": "step",
"label": "Researcher",
"meta": {},
"parent": null
},
{
"id": "step:include",
"kind": "step",
"label": "include",
"meta": {
"pattern": "include",
"recipe": "publisher"
},
"parent": null
},
{
"id": "end:done",
"kind": "end",
"label": "Delivered",
"meta": {},
"parent": null
}
],
"process": "flow",
"schema": "praisonai.graph/1"
}
@@ -1,126 +0,0 @@
{
"edges": [
{
"id": "agent:Researcher->tool:Researcher/search_web:uses",
"kind": "uses",
"label": null,
"source": "agent:Researcher",
"target": "tool:Researcher/search_web"
},
{
"id": "task:research->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "task:research",
"target": "agent:Researcher"
},
{
"id": "task:write->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:write",
"target": "agent:Writer"
},
{
"id": "start:begin->manager:Manager:next",
"kind": "next",
"label": null,
"source": "start:begin",
"target": "manager:Manager"
},
{
"id": "manager:Manager->agent:Researcher:delegates",
"kind": "delegates",
"label": null,
"source": "manager:Manager",
"target": "agent:Researcher"
},
{
"id": "manager:Manager->agent:Writer:delegates",
"kind": "delegates",
"label": null,
"source": "manager:Manager",
"target": "agent:Writer"
},
{
"id": "manager:Manager->end:done:next",
"kind": "next",
"label": null,
"source": "manager:Manager",
"target": "end:done"
},
{
"id": "task:research->task:write:depends_on",
"kind": "depends_on",
"label": null,
"source": "task:research",
"target": "task:write"
}
],
"kind": "team",
"name": "managed",
"nodes": [
{
"id": "start:begin",
"kind": "start",
"label": "Brief",
"meta": {},
"parent": null
},
{
"id": "manager:Manager",
"kind": "manager",
"label": "Manager",
"meta": {},
"parent": null
},
{
"id": "agent:Researcher",
"kind": "agent",
"label": "Researcher",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "tool:Researcher/search_web",
"kind": "tool",
"label": "search_web",
"meta": {},
"parent": null
},
{
"id": "agent:Writer",
"kind": "agent",
"label": "Writer",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "task:research",
"kind": "task",
"label": "research",
"meta": {},
"parent": null
},
{
"id": "task:write",
"kind": "task",
"label": "write",
"meta": {},
"parent": null
},
{
"id": "end:done",
"kind": "end",
"label": "Delivered",
"meta": {},
"parent": null
}
],
"process": "hierarchical",
"schema": "praisonai.graph/1"
}
@@ -1,126 +0,0 @@
{
"edges": [
{
"id": "agent:Researcher->tool:Researcher/search_web:uses",
"kind": "uses",
"label": null,
"source": "agent:Researcher",
"target": "tool:Researcher/search_web"
},
{
"id": "task:research->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "task:research",
"target": "agent:Researcher"
},
{
"id": "task:write->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:write",
"target": "agent:Writer"
},
{
"id": "task:write#2->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:write#2",
"target": "agent:Writer"
},
{
"id": "start:begin->task:research:next",
"kind": "next",
"label": null,
"source": "start:begin",
"target": "task:research"
},
{
"id": "task:research->task:write:next",
"kind": "next",
"label": null,
"source": "task:research",
"target": "task:write"
},
{
"id": "task:write->task:write#2:next",
"kind": "next",
"label": null,
"source": "task:write",
"target": "task:write#2"
},
{
"id": "task:write#2->end:done:next",
"kind": "next",
"label": null,
"source": "task:write#2",
"target": "end:done"
}
],
"kind": "team",
"name": "pair",
"nodes": [
{
"id": "start:begin",
"kind": "start",
"label": "Brief",
"meta": {},
"parent": null
},
{
"id": "agent:Researcher",
"kind": "agent",
"label": "Researcher",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "tool:Researcher/search_web",
"kind": "tool",
"label": "search_web",
"meta": {},
"parent": null
},
{
"id": "agent:Writer",
"kind": "agent",
"label": "Writer",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "task:research",
"kind": "task",
"label": "research",
"meta": {},
"parent": null
},
{
"id": "task:write",
"kind": "task",
"label": "write",
"meta": {},
"parent": null
},
{
"id": "task:write#2",
"kind": "task",
"label": "write",
"meta": {},
"parent": null
},
{
"id": "end:done",
"kind": "end",
"label": "Delivered",
"meta": {},
"parent": null
}
],
"process": "sequential",
"schema": "praisonai.graph/1"
}
@@ -1,186 +0,0 @@
{
"edges": [
{
"id": "agent:Researcher->tool:Researcher/search_web:uses",
"kind": "uses",
"label": null,
"source": "agent:Researcher",
"target": "tool:Researcher/search_web"
},
{
"id": "agent:Writer->agent:Billing:handoff",
"kind": "handoff",
"label": null,
"source": "agent:Writer",
"target": "agent:Billing"
},
{
"id": "task:research->agent:Researcher:assigned",
"kind": "assigned",
"label": null,
"source": "task:research",
"target": "agent:Researcher"
},
{
"id": "task:draft->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:draft",
"target": "agent:Writer"
},
{
"id": "human:you->task:draft:reviews",
"kind": "reviews",
"label": null,
"source": "human:you",
"target": "task:draft"
},
{
"id": "task:publish->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:publish",
"target": "agent:Writer"
},
{
"id": "task:3->agent:Writer:assigned",
"kind": "assigned",
"label": null,
"source": "task:3",
"target": "agent:Writer"
},
{
"id": "start:begin->task:research:next",
"kind": "next",
"label": null,
"source": "start:begin",
"target": "task:research"
},
{
"id": "task:research->task:draft:next",
"kind": "next",
"label": null,
"source": "task:research",
"target": "task:draft"
},
{
"id": "task:draft->task:publish:routes",
"kind": "routes",
"label": "approved",
"source": "task:draft",
"target": "task:publish"
},
{
"id": "task:draft->task:research:routes",
"kind": "routes",
"label": "rejected",
"source": "task:draft",
"target": "task:research"
},
{
"id": "task:publish->end:done:next",
"kind": "next",
"label": null,
"source": "task:publish",
"target": "end:done"
},
{
"id": "task:3->end:done:next",
"kind": "next",
"label": null,
"source": "task:3",
"target": "end:done"
}
],
"kind": "team",
"name": "newsroom",
"nodes": [
{
"id": "start:begin",
"kind": "start",
"label": "Brief",
"meta": {},
"parent": null
},
{
"id": "agent:Researcher",
"kind": "agent",
"label": "Researcher",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "tool:Researcher/search_web",
"kind": "tool",
"label": "search_web",
"meta": {},
"parent": null
},
{
"id": "agent:Writer",
"kind": "agent",
"label": "Writer",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "agent:Billing",
"kind": "agent",
"label": "Billing",
"meta": {
"role": "Assistant"
},
"parent": null
},
{
"id": "human:you",
"kind": "human",
"label": "You",
"meta": {},
"parent": null
},
{
"id": "task:research",
"kind": "task",
"label": "research",
"meta": {},
"parent": null
},
{
"id": "task:draft",
"kind": "task",
"label": "draft",
"meta": {
"human_input": true
},
"parent": null
},
{
"id": "task:publish",
"kind": "task",
"label": "publish",
"meta": {},
"parent": null
},
{
"id": "task:3",
"kind": "task",
"label": "Archive everything afterwards",
"meta": {},
"parent": null
},
{
"id": "end:done",
"kind": "end",
"label": "Delivered",
"meta": {},
"parent": null
}
],
"process": "workflow",
"schema": "praisonai.graph/1"
}
@@ -1,160 +0,0 @@
"""Graph data (``praisonai.graph/1``) for teams, flows and agents.
The replay UI draws the Map from this graph and joins it to trace events by
``node_id``, so the ids must be derived from the definition alone: identical
in every process and on every run. The goldens pin the full shape; regenerate
them deliberately with ``PRAISONAI_UPDATE_GOLDEN=1``.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).parent
GOLDEN = HERE / "golden"
sys.path.insert(0, str(HERE))
import _graph_fixtures as fixtures # noqa: E402
from praisonaiagents.workflows.diagram import graph_to_mermaid, to_graph # noqa: E402
@pytest.mark.parametrize("name", ["team", "sequential_team", "hierarchical_team", "flow"])
def test_graph_matches_golden(name):
graph = to_graph(fixtures.BUILDERS[name]())
path = GOLDEN / f"graph_{name}.json"
if os.environ.get("PRAISONAI_UPDATE_GOLDEN") == "1":
path.write_text(json.dumps(graph, indent=2, sort_keys=True) + "\n")
assert graph == json.loads(path.read_text())
def test_ids_are_stable_across_processes():
"""Two fresh interpreters with different hash seeds produce the same graphs."""
code = (
"import json, sys; sys.path.insert(0, sys.argv[1]);"
"import _graph_fixtures as f;"
"from praisonaiagents.workflows.diagram import to_graph;"
"print(json.dumps({k: to_graph(b()) for k, b in sorted(f.BUILDERS.items())}, sort_keys=True))"
)
root = str(Path(__import__("praisonaiagents").__file__).parent.parent)
outputs = []
for seed in ("1", "2"):
env = dict(os.environ, PYTHONHASHSEED=seed, PYTHONPATH=root, PYTHONDONTWRITEBYTECODE="1")
out = subprocess.run([sys.executable, "-c", code, str(HERE)], env=env,
capture_output=True, text=True, timeout=120, check=True)
outputs.append(json.loads(out.stdout.strip().splitlines()[-1]))
assert outputs[0] == outputs[1]
assert outputs[0]["flow"] == to_graph(fixtures.flow())
def test_every_edge_joins_known_nodes_and_ids_are_unique():
for build in fixtures.BUILDERS.values():
graph = to_graph(build())
ids = [n["id"] for n in graph["nodes"]]
assert len(ids) == len(set(ids))
for edge in graph["edges"]:
assert edge["source"] in ids and edge["target"] in ids, edge
assert edge["id"] == f'{edge["source"]}->{edge["target"]}:{edge["kind"]}'
for node in graph["nodes"]:
assert node["parent"] is None or node["parent"] in ids
def test_no_uuid_reaches_the_graph():
import re
uuid = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
for build in fixtures.BUILDERS.values():
assert not uuid.search(json.dumps(to_graph(build())))
def test_team_and_flow_methods():
team = fixtures.hierarchical_team()
assert team.to_graph() == to_graph(team)
diagram = team.to_mermaid()
assert diagram.startswith("graph TD")
assert '{{"Manager"}}' in diagram and '-->|"delegates"|' in diagram
flow = fixtures.flow()
assert flow.to_graph() == to_graph(flow)
def test_agent_graph_has_tools_and_handoffs():
from praisonaiagents import Agent
billing = Agent(name="Billing", instructions="b")
triage = Agent(name="Triage", instructions="t", tools=[fixtures.search_web], handoffs=[billing])
graph = to_graph(triage)
assert graph["kind"] == "agent"
edges = {e["id"] for e in graph["edges"]}
assert {
"start:begin->agent:Triage:next",
"agent:Triage->tool:Triage/search_web:uses",
"agent:Triage->agent:Billing:handoff",
"agent:Triage->end:done:next",
} <= edges
# The hand-off's generated transfer tool is the hand-off edge, not a tool node.
assert not any(n["id"].startswith("tool:Triage/transfer_to") for n in graph["nodes"])
def test_graph_to_mermaid_draws_leaf_nodes_not_groups():
graph = to_graph(fixtures.flow())
diagram = graph_to_mermaid(graph)
assert diagram.startswith("graph TD")
assert '(("fork"))' in diagram and '{"route"}' in diagram
assert '["parallel"]' not in diagram # a pattern group is a container
def test_team_graph_keeps_when_routes_and_shared_target_labels():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
a = Agent(name="A", instructions="a")
tasks = [
Task(name="check", description="c", agent=a, is_start=True,
when="score > 80", then_task="publish", else_task="revise"),
Task(name="revise", description="r", agent=a,
condition={"approved": ["publish"], "forced": ["publish"]}),
Task(name="publish", description="p", agent=a),
]
graph = to_graph(AgentTeam(agents=[a], tasks=tasks, process="workflow"))
edges = {e["id"]: e for e in graph["edges"]}
assert edges["task:check->task:publish:routes"]["label"] == "score > 80"
assert edges["task:check->task:revise:routes"]["label"] == "else"
assert "task:check->end:done:next" not in edges
assert edges["task:revise->task:publish:routes"]["label"] == "approved / forced"
def test_shared_target_keeps_a_label_that_looks_like_a_merged_one():
from praisonaiagents import Agent, Task
from praisonaiagents.agents.agents import AgentTeam
a = Agent(name="A", instructions="a")
tasks = [
Task(name="revise", description="r", agent=a, is_start=True,
condition={"approved / forced": ["publish"], "approved": ["publish"]}),
Task(name="publish", description="p", agent=a),
]
graph = to_graph(AgentTeam(agents=[a], tasks=tasks, process="workflow"))
edges = {e["id"]: e for e in graph["edges"]}
assert edges["task:revise->task:publish:routes"]["label"] == "approved / forced / approved"
def test_repeat_with_a_step_list_draws_each_member():
from praisonaiagents.workflows.workflows import AgentFlow, Repeat
def draft(ctx):
return "d"
def polish(ctx):
return "p"
graph = to_graph(AgentFlow(steps=[Repeat(step=[draft, polish], until=lambda ctx: True)]))
ids = {n["id"] for n in graph["nodes"]}
assert {"step:draft", "step:polish"} <= ids
assert "step:list" not in ids
def test_unsupported_object_is_rejected():
with pytest.raises(TypeError):
to_graph(42)
@@ -1,28 +0,0 @@
"""Route references must not silently discard undefined agents."""
import pytest
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
PREFIX = """name: route-validation
agents:
known:
name: Known
instructions: Greet.
llm: openai/fixture-model
steps:
"""
@pytest.mark.parametrize("target", ["missing", "[known, missing]"])
def test_route_rejects_undefined_target(target):
source = PREFIX + f" - route:\n chosen: {target}\n"
with pytest.raises(ValueError, match="Agent 'missing'.*route 'chosen'"):
YAMLWorkflowParser().parse_string(source)
@pytest.mark.parametrize("target", ["known", "[known]"])
def test_route_keeps_defined_targets(target):
source = PREFIX + f" - route:\n chosen: {target}\n"
flow = YAMLWorkflowParser().parse_string(source)
assert [agent.name for agent in flow.steps[0].routes["chosen"]] == ["Known"]
+3 -3
View File
@@ -3162,7 +3162,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.7.19"
version = "1.7.16"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -3292,8 +3292,8 @@ requires-dist = [
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.83.14,<1.106" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14,<1.106" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.83.14" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0,<2" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
@@ -14,7 +14,6 @@ _LAZY_EXPORTS = {
"praisonai_bot.gateway.admin",
"provision_gateway_config",
),
"edit_gateway_channel": ("praisonai_bot.gateway.admin", "edit_gateway_channel"),
}
@@ -35,6 +34,5 @@ __all__ = [
"GatewayRepairResult",
"repair_gateway_config",
"provision_gateway_config",
"edit_gateway_channel",
"__version__",
]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.18"
__version__ = "0.1.15"
@@ -468,15 +468,10 @@ async def deliver_media_to_adapter(
)
return False
# Resolve the thread *under* the parent channel (as ``send_message``
# does) rather than by global id, so a target's thread segment can
# never redirect the upload to an unrelated channel (#6084).
channel = client.get_channel(int(channel_id))
if channel is not None and thread_id is not None:
get_thread = getattr(channel, "get_thread", None)
thread = get_thread(int(thread_id)) if callable(get_thread) else None
if thread is not None:
channel = thread
target_id = thread_id or channel_id
channel = client.get_channel(int(target_id))
if channel is None and thread_id is not None:
channel = client.get_channel(int(channel_id))
if channel is not None:
await channel.send(
content=caption or None, file=discord.File(path)
@@ -102,27 +102,6 @@ class BotOutboundMessenger:
resolved = f"{platform}:{channel_id}"
# Destination authorization (#6084): the target is model-controlled, so
# an explicit "<platform>:<chat_id>" must name a chat this gateway
# already knows (the turn's origin, or a home/alias/observed channel)
# before anything — text or media — is delivered. Symbolic tokens
# ("origin", a platform home, an alias) resolve to known chats by
# construction. Operators widen the set via the alias overlay.
if not self._is_attested(platform, channel_id):
logger.warning(
"BotOutboundMessenger: refusing send to unattested destination %s",
resolved,
)
return DeliveryResult(
ok=False,
target=resolved,
summary=f"Refused: {resolved} is not a known destination for this gateway.",
detail=(
"destination not attested (not the origin chat, a home "
"channel, an alias, or an observed channel)"
),
)
# Idempotency covers the *whole* text+media envelope (issue #2578). A
# duplicate proactive send must skip both the text and any media upload,
# otherwise a re-fired job with attachments would double-upload media
@@ -204,21 +183,6 @@ class BotOutboundMessenger:
detail=detail,
)
def _is_attested(self, platform: str, channel_id: str) -> bool:
"""Whether ``platform:channel_id`` is a chat this gateway knows."""
origin = self._origin
if (
origin is not None
and (origin.platform or "").lower() == platform.lower()
and str(origin.channel_id) == str(channel_id)
):
return True
try:
return bool(self._router.directory.has_channel(platform, str(channel_id)))
except Exception as e: # pragma: no cover — defensive, fail closed
logger.warning("BotOutboundMessenger: attestation check failed: %s", e)
return False
async def _deliver_media(
self,
target: str,
@@ -15,26 +15,12 @@ from dataclasses import dataclass, replace
from enum import Enum
from typing import Any, Dict, Optional, Protocol, TYPE_CHECKING
from ._chunk import _calculate_length, chunk_message, enforce_hard_cap
if TYPE_CHECKING:
from praisonaiagents.streaming.events import StreamEvent
logger = logging.getLogger(__name__)
def _fit_prefix(text: str, limit: int, unit: str) -> str:
"""Longest prefix of *text* within *limit* in *unit*; single O(limit) pass."""
if unit != "utf16":
return text[:limit]
used = 0
for i, ch in enumerate(text):
used += 2 if ord(ch) > 0xFFFF else 1
if used > limit:
return text[:i]
return text
# Matches <think>...</think> and <reasoning>...</reasoning> spans (case-insensitive,
# spanning newlines). Also strips a trailing unclosed opening tag so partial
# reasoning never leaks while a block is still streaming.
@@ -389,10 +375,7 @@ class DraftStreamer:
self._can_edit = caps.get('live_edit', True) # Assume true for backward compat
self._text_limit = caps.get('text_limit', 0) or 0 # 0 = unlimited
self._edit_rate_limit = caps.get('edit_rate_limit', 0) or 0
# Telegram measures its cap in UTF-16 units; default to codepoints.
unit = getattr(getattr(adapter, 'platform_capabilities', None), 'length_unit', None)
self._length_unit = unit if unit in ("codepoints", "utf16") else "codepoints"
# Per-stream runtime interval. Adaptive backoff mutates this, NOT the
# shared config, so a flood in one stream never leaks into others.
self._current_min_interval = self._config.min_interval
@@ -638,8 +621,8 @@ class DraftStreamer:
content = redact_outbound_text(content, self._adapter)
# Apply text limit if configured
if self._text_limit > 0 and _calculate_length(content, self._length_unit) > self._text_limit:
content = _fit_prefix(content, max(1, self._text_limit - 3), self._length_unit) + "..."
if self._text_limit > 0 and len(content) > self._text_limit:
content = content[:self._text_limit - 3] + "..."
return content
@@ -732,20 +715,10 @@ class DraftStreamer:
# still gets the safe-by-default guarantee. The adapter carries any
# injected redactor / opt-out so one policy governs every outbound seam.
final_content = redact_outbound_text(final_content, self._adapter)
# Over-cap answers cannot be delivered as one edit/send (both fail with
# "message too long" and the reply is lost). Edit the placeholder with
# the first chunk and send the rest as follow-ons (Issue #6078).
chunks = [final_content]
if self._text_limit > 0 and _calculate_length(final_content, self._length_unit) > self._text_limit:
chunks = enforce_hard_cap(
chunk_message(final_content, self._text_limit, length_unit=self._length_unit),
self._text_limit, length_unit=self._length_unit,
)
if self._rate_limiter:
await self._rate_limiter.acquire(self._channel_id)
# Primary delivery: when progressive edits are still healthy, edit the
# existing placeholder in place. When they were disabled by flooding, a
# fresh edit is likely to fail too, so try the placeholder edit first
@@ -753,35 +726,23 @@ class DraftStreamer:
# new send below.
delivered = False
try:
await self._adapter.edit_message(self._channel_id, self._message_id, chunks[0])
await self._adapter.edit_message(self._channel_id, self._message_id, final_content)
delivered = True
except Exception as e: # noqa: BLE001 - adapter boundary; preserve final delivery
logger.warning("DraftStreamer final edit failed: %s", e)
# Fallback: if the edit failed (or progressive editing was disabled by
# flooding), deliver the full answer as a fresh send so the user ALWAYS
# receives the completed answer regardless of the progressive path taken.
if not delivered:
try:
await self._adapter.send_message(self._channel_id, chunks[0])
await self._adapter.send_message(self._channel_id, final_content)
delivered = True
except Exception as send_exc: # noqa: BLE001 - last-resort delivery fallback
logger.warning(
"DraftStreamer final send fallback also failed: %s", send_exc
)
# Follow-on chunks: adapters' send_message already retries and parks
# exhausted sends in the DLQ, so do NOT wrap it again (a second retry
# layer would re-send a chunk that is already parked -> duplicates).
for chunk in chunks[1:]:
if self._rate_limiter:
await self._rate_limiter.acquire(self._channel_id)
try:
await self._adapter.send_message(self._channel_id, chunk)
except Exception as chunk_exc: # noqa: BLE001 - keep delivering remaining chunks
delivered = False
logger.warning("DraftStreamer follow-on chunk send failed: %s", chunk_exc)
logger.debug(
"DraftStreamer finalized message %s, final_length=%d, progressive=%s, delivered=%s",
self._message_id, len(final_content), self._progressive, delivered
@@ -551,9 +551,27 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
pass
return
final_content = send_result["content"]
# Over-cap answers are chunked by the shared
# streamer (Issue #6078).
await streamer.finalize(final_content)
# A streamed answer longer than Discord's hard
# cap cannot be delivered by editing the single
# placeholder (both the edit and the send
# fallback 400 on >2000 chars), which would drop
# the whole reply. Fall back to the chunked
# reply path so long answers are split and keep
# the reply reference (Greptile #3 / #5).
_cap = min(self.config.max_message_length, 2000)
if len(final_content) > _cap:
try:
await self.delete_message(
channel_id, placeholder_message_id
)
except Exception:
pass
await self._send_long_message(
message.channel, final_content,
reference=message,
)
else:
await streamer.finalize(final_content)
self.fire_message_sent(channel_id, final_content)
except Exception:
try:
@@ -1553,16 +1553,8 @@ def gateway_test(
raise typer.Exit(1)
channels_app = typer.Typer(
help="List, probe and edit channels in gateway.yaml",
invoke_without_command=True,
)
app.add_typer(channels_app, name="channels")
@channels_app.callback()
@app.command("channels")
def gateway_channels(
ctx: typer.Context,
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
probe: bool = typer.Option(False, "--probe", help="Probe each channel's credentials"),
@@ -1582,9 +1574,6 @@ def gateway_channels(
import os
import yaml
if ctx.invoked_subcommand is not None:
return
if available:
try:
from praisonai_bot.bots._registry import list_platforms
@@ -1673,125 +1662,6 @@ def gateway_channels(
print(f"{name:<20} {platform:<12} {has_token:<12}")
def _parse_channel_settings(pairs: Optional[List[str]]) -> dict:
"""Parse repeated ``--set key=value`` flags; values are YAML-typed."""
import yaml
settings = {}
for pair in pairs or []:
key, sep, value = pair.partition("=")
if not sep or not key.strip():
print(f"Error: --set expects key=value, got {pair!r}")
raise typer.Exit(1)
settings[key.strip()] = yaml.safe_load(value) if value else ""
return settings
def _edit_channel(
ctx: typer.Context, config: Optional[str], name: str, settings=None, *, remove=False, must_exist=True
) -> None:
"""Run one validated channel edit, mapping failures to CLI exits (#6079)."""
import os
import yaml
from praisonai_bot.gateway.admin import _editable_channels, edit_gateway_channel
# ``gateway channels --config X <cmd>`` applies to the subcommand too.
config = _resolve_doctor_config(config or ctx.parent.params.get("config") or "gateway.yaml")
if not os.path.exists(config):
print(f"Error: Config file not found: {config}")
raise typer.Exit(1)
with open(config) as fh:
exists = name in _editable_channels(yaml.safe_load(fh) or {})
if must_exist and not exists:
print(f"Error: channel {name!r} is not configured in {config}")
raise typer.Exit(1)
if not must_exist and exists:
print(f"Error: channel {name!r} already exists in {config}; use 'update'")
raise typer.Exit(1)
try:
edit_gateway_channel(config, name, settings, remove=remove)
except ValueError as exc:
print(f"Error: {exc}\nNo changes written to {config}.")
raise typer.Exit(1) from exc
@channels_app.command("add")
def gateway_channels_add(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name, e.g. 'telegram'"),
platform: Optional[str] = typer.Option(None, "--platform", help="Platform (default: the channel name)"),
token: Optional[str] = typer.Option(
None, "--token",
help="Channel token (default: ${VAR} reference to the platform's credential env var)",
),
set_: Optional[List[str]] = typer.Option(None, "--set", help="Extra setting key=value (repeatable)"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Add one channel to gateway.yaml without touching the others.
Examples:
praisonai gateway channels add telegram
praisonai gateway channels add support --platform discord --set group_policy=mention_only
"""
from praisonai_bot.bots._config_schema import GatewayConfigSchema
platform = platform or name
settings = {"platform": platform}
settings["token"] = token if token is not None else GatewayConfigSchema._credential_env_ref(platform)
settings.update(_parse_channel_settings(set_))
_edit_channel(ctx, config, name, settings, must_exist=False)
print(f"Added channel {name!r}.")
@channels_app.command("update")
def gateway_channels_update(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to update"),
token: Optional[str] = typer.Option(None, "--token", help="New channel token"),
set_: Optional[List[str]] = typer.Option(None, "--set", help="Setting key=value (repeatable)"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Update settings of one configured channel.
Examples:
praisonai gateway channels update telegram --set group_policy=mention_only
"""
settings = _parse_channel_settings(set_)
if token is not None:
settings["token"] = token
if not settings:
print("Error: nothing to update; pass --set key=value, --token, or both")
raise typer.Exit(1)
_edit_channel(ctx, config, name, settings)
print(f"Updated channel {name!r}.")
@channels_app.command("disable")
def gateway_channels_disable(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to disable"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Stop serving a channel but keep its config (sets ``enabled: false``).
Re-enable with ``praisonai gateway channels update <name> --set enabled=true``.
"""
_edit_channel(ctx, config, name, {"enabled": False})
print(f"Disabled channel {name!r}.")
@channels_app.command("remove")
def gateway_channels_remove(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to remove"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Delete a channel block from gateway.yaml."""
_edit_channel(ctx, config, name, remove=True)
print(f"Removed channel {name!r}.")
def _resolve_gateway_rest_url(
url: Optional[str],
host: Optional[str] = None,
@@ -15,7 +15,6 @@ one canonical flow (DRY).
from __future__ import annotations
import contextlib
import os
import sys
from dataclasses import dataclass, field
@@ -202,6 +201,7 @@ def _repair_config_version(config_path: str):
an interruption, full disk, or I/O error during ``doctor --fix`` can never
leave the config truncated or half-written.
"""
import tempfile
import yaml
with open(config_path) as fh:
@@ -210,14 +210,6 @@ def _repair_config_version(config_path: str):
from praisonaiagents.gateway.config import migrate_config_with_doctor
migrated, reasons = migrate_config_with_doctor(cfg)
_atomic_write_yaml(config_path, migrated)
return reasons
def _atomic_write_yaml(config_path: str, data: dict) -> None:
"""Serialise ``data`` to a temp file and ``os.replace`` it over the config."""
import tempfile
import yaml
directory = os.path.dirname(os.path.abspath(config_path))
fd, tmp_path = tempfile.mkstemp(
@@ -225,7 +217,9 @@ def _atomic_write_yaml(config_path: str, data: dict) -> None:
)
try:
with os.fdopen(fd, "w") as fh:
yaml.safe_dump(data, fh, default_flow_style=False, sort_keys=False)
yaml.safe_dump(
migrated, fh, default_flow_style=False, sort_keys=False
)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, config_path)
@@ -236,127 +230,7 @@ def _atomic_write_yaml(config_path: str, data: dict) -> None:
pass
raise
def edit_gateway_channel(
config_path: PathLike,
name: str,
settings: Optional[Dict] = None,
*,
remove: bool = False,
) -> dict:
"""Add, update or remove one ``channels:`` entry in place (Issue #6079).
A surgical read-modify-write: ``settings`` is merged into
``channels[name]`` (created if absent, ``platform`` defaulting to ``name``)
or, with ``remove=True``, the block is deleted. Other channels and
top-level keys are untouched. The edited document is validated against
``GatewayConfigSchema`` before the atomic write, so an invalid mutation is
rejected rather than persisted. Disable a channel with
``settings={"enabled": False}``.
Returns:
The resulting channel block (``{}`` when removed).
Raises:
FileNotFoundError: If ``config_path`` does not exist.
KeyError: If ``remove=True`` and the channel is not configured.
ValueError: If the edited config fails validation.
"""
import yaml
config_path = os.fspath(config_path)
if not os.path.isfile(config_path):
raise FileNotFoundError(config_path)
# Hold a sidecar lock across read-modify-write so concurrent edits of
# different channels cannot overwrite each other.
with _config_edit_lock(config_path):
with open(config_path) as fh:
cfg = yaml.safe_load(fh) or {}
return _apply_channel_edit(config_path, cfg, name, settings, remove)
def _apply_channel_edit(config_path, cfg, name, settings, remove) -> dict:
from praisonai_bot.bots._config_schema import validate_gateway_config
channels = _editable_channels(cfg)
if remove:
if name not in channels:
raise KeyError(f"channel {name!r} is not configured in {config_path}")
channels.pop(name)
block: dict = {}
else:
block = dict(channels.get(name) or {})
block.setdefault("platform", name)
block.update(settings or {})
channels[name] = block
cfg["channels"] = channels
# Check the document itself: the validator may otherwise satisfy its
# "at least one channel" rule from env credentials, which are not saved.
if not any(
not isinstance(b, dict) or b.get("enabled", True) is not False
for b in channels.values()
):
raise ValueError("at least one enabled channel must remain configured")
# Validate an env-resolved copy (as the runtime does; unset ``${VAR}``
# refs are left as-is) while the saved file keeps the references.
validate_gateway_config(cfg)
_atomic_write_yaml(config_path, cfg)
return block
def _editable_channels(cfg: dict) -> dict:
"""Return ``cfg``'s ``channels`` map, moving legacy declarations into it.
Single-bot (top-level ``platform``) and BotOS (``platforms``) files are
only honoured by the loader while ``channels`` is empty, so they are
converted in place before an edit adds a ``channels:`` block. ``${VAR}``
references are copied verbatim.
"""
channels = cfg.get("channels") or {}
if channels:
return channels
platform = cfg.get("platform")
if platform:
# Mirrors the loader's single-bot migration, which wins over BotOS.
from praisonai_bot.bots._config_schema import GatewayConfigSchema
# Resolve a ``${VAR}`` platform first, as the loader does.
raw = str(cfg.pop("platform"))
platform = str(_resolve_env_token(raw) or raw).lower().strip()
block = {"platform": platform}
token = cfg.pop("token", None)
block["token"] = token or GatewayConfigSchema._credential_env_ref(platform)
if platform == "slack" and cfg.get("app_token") is not None:
block["app_token"] = cfg.pop("app_token")
for key in ("unknown_user_policy", "owner_user_id"):
if cfg.get(key) is not None:
block[key] = cfg[key]
channels[platform] = block
elif cfg.get("platforms"):
for pname, pcfg in cfg.pop("platforms").items():
channels[pname] = {"platform": pname, **(pcfg or {})}
return channels
@contextlib.contextmanager
def _config_edit_lock(config_path: str):
"""Exclusive OS advisory lock on ``<config>.lock`` (no-op if unsupported)."""
with open(config_path + ".lock", "a+") as fh:
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
else: # pragma: no cover - Windows
try:
import msvcrt
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
except Exception:
pass
yield # released when ``fh`` closes
return reasons
def _repair_gateway_secret(dry_run: bool = False, config_path: str = ""):
@@ -382,16 +382,12 @@ class PermissionAllowlist:
path: Optional[Union[str, Path]] = None,
) -> None:
self._lock = threading.Lock()
# In-memory mirror: (agent_id, tool_name, arg_signature) -> created_at.
# Stamped so the grant TTL holds on the hot path for the whole process
# lifetime, not only when the durable store is re-read on restart.
self._allowed: Dict[tuple, float] = {}
# In-memory mirror of grant keys (agent_id, tool_name, arg_signature)
self._allowed: Set[tuple] = set()
self._store: Optional[ScopedAllowlistStore] = None
self._ttl_seconds = _DEFAULT_GRANT_TTL_SECONDS
if durable:
try:
self._store = store or ScopedAllowlistStore(path=path)
self._ttl_seconds = self._store.ttl_seconds
self._rehydrate()
except Exception:
# Fail-closed on the persistence path: if the store cannot be
@@ -413,21 +409,13 @@ class PermissionAllowlist:
grant["tool_name"],
grant.get("arg_signature") or "",
)
self._allowed[key] = float(grant.get("created_at") or time.time())
self._allowed.add(key)
if self._allowed:
logger.info(
"Rehydrated %d durable allow-list grant(s) from %s",
len(self._allowed), self._store.path,
)
def _evict_expired_locked(self) -> None:
"""Drop grants older than the TTL from the in-memory mirror."""
if self._ttl_seconds <= 0:
return
cutoff = time.time() - self._ttl_seconds
for key in [k for k, ts in self._allowed.items() if ts <= cutoff]:
del self._allowed[key]
# ── Scoped API ───────────────────────────────────────────────────
def add_scoped(
self,
@@ -440,7 +428,7 @@ class PermissionAllowlist:
"""Add a scoped grant, persisting it when durable."""
key = (agent_id, tool_name, arg_signature or "")
with self._lock:
self._allowed[key] = time.time()
self._allowed.add(key)
if self._store is not None:
try:
self._store.add(
@@ -469,17 +457,11 @@ class PermissionAllowlist:
(empty signature) covers any arguments.
"""
sig = arg_signature or ""
cutoff = time.time() - self._ttl_seconds if self._ttl_seconds > 0 else None
with self._lock:
# Only the candidate keys are checked (O(1)); expired ones are
# evicted lazily. Full sweeps happen in list()/list_scoped().
for a in (agent_id, ANY_AGENT):
for key in ((a, tool_name, ""), (a, tool_name, sig) if sig else None):
if key is None or key not in self._allowed:
continue
if cutoff is not None and self._allowed[key] <= cutoff:
del self._allowed[key]
continue
if (a, tool_name, "") in self._allowed:
return True
if sig and (a, tool_name, sig) in self._allowed:
return True
return False
@@ -496,11 +478,12 @@ class PermissionAllowlist:
if arg_signature is None:
for key in list(self._allowed):
if key[0] == agent_id and key[1] == tool_name:
del self._allowed[key]
self._allowed.discard(key)
removed = True
else:
key = (agent_id, tool_name, arg_signature)
if self._allowed.pop(key, None) is not None:
if key in self._allowed:
self._allowed.discard(key)
removed = True
if self._store is not None:
try:
@@ -525,7 +508,6 @@ class PermissionAllowlist:
except Exception:
logger.exception("Failed to list persisted grants; using memory")
with self._lock:
self._evict_expired_locked()
items = [
{
"agent_id": a,
@@ -548,7 +530,7 @@ class PermissionAllowlist:
with self._lock:
for key in list(self._allowed):
if key[1] == tool_name:
del self._allowed[key]
self._allowed.discard(key)
removed = True
if self._store is not None:
try:
@@ -569,7 +551,6 @@ class PermissionAllowlist:
grants are exposed via :meth:`list_scoped` / the ``grants`` view.
"""
with self._lock:
self._evict_expired_locked()
return sorted(
{key[1] for key in self._allowed if key[0] == ANY_AGENT}
)
@@ -11,13 +11,11 @@ import logging
import math
import os
import re
import signal
import subprocess
import threading
import time
from typing import Dict, Any, List, Optional
from .._guarded_subprocess import _kill_process_group, run_guarded
from .._guarded_subprocess import run_guarded
logger = logging.getLogger(__name__)
@@ -88,17 +86,6 @@ class KanbanDispatcher:
self._task_runs: Dict[str, int] = {} # task_id -> open run_id
self.worker_id = f"gateway_{os.getpid()}"
# Serialises blocking git/SQLite work handed to worker threads, so two
# integrations never run git concurrently in the same base checkout.
_blocking_lock = threading.Lock()
async def _run_blocking(self, fn, *args):
"""Run a blocking (git / SQLite) helper off the event loop."""
def _locked():
with self._blocking_lock:
return fn(*args)
return await asyncio.to_thread(_locked)
def _close_run_safe(self, store, run_id, outcome, *, summary=None, metadata=None, error=None) -> bool:
"""Close a run, swallowing errors so dispatch stays resilient.
@@ -209,13 +196,8 @@ class KanbanDispatcher:
if not store:
return 0
# Clean up completed processes first. Integration runs several git
# commands, so keep it off the event loop.
await self._run_blocking(self._cleanup_completed_tasks, store)
# Prove liveness for our own children before the reclaim sweep runs,
# so a healthy long-running worker is never mistaken for a stale one.
self._heartbeat_children(store)
# Clean up completed processes first
self._cleanup_completed_tasks(store)
# Reclaim stale/crashed claims before dispatching new work so that
# tasks stranded by dead workers return to 'ready' and get re-dispatched.
@@ -239,14 +221,6 @@ class KanbanDispatcher:
spawned = 0
for task in ready_tasks[:available_slots]:
live = self.running_tasks.get(task.id)
if live is not None and live.poll() is None:
# Never overwrite the handle of a still-running worker.
logger.error(
"Task %s is 'ready' but its worker (pid %s) is still running; "
"skipping re-dispatch", task.id, live.pid,
)
continue
try:
# Try to claim the task atomically with a lease + owner PID.
claimed = self._claim_task(store, task)
@@ -271,24 +245,8 @@ class KanbanDispatcher:
)
continue
# Spawn worker process. If the dispatcher is stopped while the
# worktree is being prepared, no worker exists yet: close the
# run and hand the claim back before propagating.
try:
success = await self._spawn_worker(task, store)
except asyncio.CancelledError:
self._close_run_safe(
store, run_id, 'failed',
error='Dispatcher stopped before worker spawn',
)
try:
store.release_claim(task.id, self.worker_id)
except Exception as release_err:
logger.error(
"Failed to release claim for %s on cancel: %s",
task.id, release_err,
)
raise
# Spawn worker process
success = await self._spawn_worker(task, store)
if not success:
# Failed to spawn: close run as failed, count it, and only
# release the claim for retry when the failure was recorded
@@ -436,69 +394,6 @@ class KanbanDispatcher:
(pid, datetime.now(timezone.utc).isoformat(), task_id, self.worker_id),
)
def _heartbeat_children(self, store: Any):
"""Renew lease + heartbeat for every child worker that is still alive.
The spawned worker cannot heartbeat for itself, so the supervisor that
holds its ``Popen`` handle does it. If the claim is gone (an operator
released or moved the card), the child is terminated rather than left
running unowned.
"""
beat = getattr(store, 'heartbeat', None)
if beat is None:
return
for task_id, process in list(self.running_tasks.items()):
if process.poll() is not None:
continue
try:
owned = beat(task_id, self.worker_id, ttl_seconds=self.claim_ttl_seconds)
except Exception as e:
logger.debug(f"Heartbeat for task {task_id} failed: {e}")
continue
if not owned:
logger.warning(
f"Lost claim on task {task_id} while worker {process.pid} "
f"was running; terminating it"
)
self._terminate_unowned(store, task_id, process)
def _terminate_unowned(self, store: Any, task_id: str, process: subprocess.Popen):
"""Stop a child whose claim is no longer ours and forget its tracking."""
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
except OSError as e:
logger.error(f"Failed to terminate worker for task {task_id}: {e}")
self.running_tasks.pop(task_id, None)
entry = getattr(self, '_worktrees', {}).get(task_id)
if entry:
# Lossless teardown: keep tracking (and say why) if work remains.
preserved = self._remove_worktree(*entry)
if preserved is None:
self._worktrees.pop(task_id, None)
else:
try:
store.add_comment(
task_id, self.worker_id,
f"worktree_preserved at {entry[0]}: {preserved}",
)
except Exception:
pass
log_path = getattr(self, '_temp_logs', {}).pop(task_id, None)
if log_path:
try:
os.unlink(log_path)
except OSError:
pass
run_id = self._task_runs.get(task_id)
if run_id is None or self._close_run_safe(
store, run_id, 'failed',
error='Claim lost while worker was running; worker terminated',
):
self._task_runs.pop(task_id, None)
def _reclaim_stale_claims(self, store: Any):
"""Sweep running tasks and reclaim those stranded by dead/stale workers."""
reclaim = getattr(store, 'reclaim_stale_claims', None)
@@ -514,11 +409,6 @@ class KanbanDispatcher:
for task_id in reclaimed or []:
logger.warning(f"Reclaimed stale kanban task {task_id} back to 'ready'")
# The store closed the abandoned run as 'crashed' atomically with
# the reclaim; count the attempt so the circuit breaker sees it.
self._record_failure_safe(
store, task_id, error='Worker died or went stale; claim reclaimed'
)
# The running -> ready transition is announced as
# KANBAN_TASK_MOVED (reclaimed=True) by the store itself;
# 'KANBAN_TASK_RECLAIMED' was never a HookEvent member and so
@@ -694,11 +584,6 @@ class KanbanDispatcher:
return f"worktree removal error: {e}"
return None
def _discard_worktree(self, path: str, branch: str) -> None:
"""Remove a worktree and branch that no worker ever used."""
if self._remove_worktree(path, force=True) is None:
self._run_git("branch", "-D", branch)
def _commit_worktree_changes(self, path: str, branch: str) -> None:
"""Commit any uncommitted edits the worker left in its worktree.
@@ -839,22 +724,7 @@ class KanbanDispatcher:
# cwd so nothing regresses.
worktree_path = None
if getattr(task, 'workspace_kind', 'default') == 'worktree':
prep = asyncio.ensure_future(
self._run_blocking(self._prepare_worktree, task, store)
)
try:
worktree_path = await asyncio.shield(prep)
except asyncio.CancelledError:
# The thread keeps running after cancellation. Let it
# finish, then drop the fresh (unused) worktree and branch
# so a retry can recreate them with `worktree add -b`.
path = await asyncio.shield(prep)
if path:
await self._run_blocking(
self._discard_worktree, path,
f"kanban/{self._safe_task_id(task.id)}",
)
raise
worktree_path = self._prepare_worktree(task, store)
if worktree_path:
if not hasattr(self, '_worktrees'):
self._worktrees = {}
@@ -883,9 +753,6 @@ class KanbanDispatcher:
stderr=subprocess.STDOUT,
text=True,
cwd=worktree_path,
# Own process group so shutdown can stop the worker's
# descendants too (POSIX; ignored on Windows).
start_new_session=(os.name != 'nt'),
)
# parent FD closed here; child still has its duped copy
@@ -1138,27 +1005,30 @@ class KanbanDispatcher:
while self.running_tasks and (time.time() - start_time) < timeout:
store = self._get_kanban_store()
if store:
await self._run_blocking(self._cleanup_completed_tasks, store)
self._cleanup_completed_tasks(store)
await asyncio.sleep(1)
# Force terminate remaining tasks: signal every worker group
# first, then wait for them together off the event loop.
# Force terminate remaining tasks
store = self._get_kanban_store()
for task_id, process in self.running_tasks.items():
logger.warning(f"Force terminating task {task_id}")
_terminate_worker(process)
await asyncio.gather(*(
asyncio.to_thread(_wait_quiet, process, 5)
for process in self.running_tasks.values()
))
for task_id, process in self.running_tasks.items():
if process.poll() is None:
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning(f"Task {task_id} did not terminate in 5s; sending SIGKILL")
# Even if the worker exited, a descendant may have ignored
# SIGTERM, so always finish off the whole group.
_kill_worker_group(process)
try:
process.kill()
except OSError as kill_err:
logger.error(f"Failed to kill task {task_id}: {kill_err}")
except OSError as os_err:
logger.error(f"OS error while terminating task {task_id}: {os_err}")
try:
process.kill()
except OSError as kill_err:
logger.error(f"Failed to kill task {task_id}: {kill_err}")
# KeyboardInterrupt, SystemExit, CancelledError now propagate as intended
for task_id in list(self.running_tasks):
# Close any open run for the interrupted attempt so we do not
# leave dangling task_runs rows / stale current_run_id pointers.
run_id = self._task_runs.pop(task_id, None)
@@ -1174,49 +1044,6 @@ class KanbanDispatcher:
logger.info("Kanban dispatcher shutdown complete")
def _terminate_worker(process: subprocess.Popen) -> None:
"""Ask a worker and its descendants to exit (SIGTERM to its group)."""
try:
if os.name != 'nt':
os.killpg(process.pid, signal.SIGTERM)
else:
process.terminate()
except OSError:
try:
process.terminate()
except OSError as err:
logger.error(f"Failed to terminate worker {process.pid}: {err}")
def _kill_worker_group(process: subprocess.Popen) -> None:
"""SIGKILL a worker's group, even after the worker itself was reaped.
The worker leads its own session, so its group id equals its pid and the
group stays addressable while any descendant is alive.
"""
if os.name == 'nt':
if process.poll() is None:
_kill_process_group(process)
return
try:
os.killpg(process.pid, signal.SIGKILL)
except OSError:
pass # group already empty
if process.poll() is None:
try:
process.kill()
except OSError:
pass
def _wait_quiet(process: subprocess.Popen, timeout: float) -> None:
"""Wait for a process, ignoring a timeout (the caller escalates)."""
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
pass
# Global dispatcher management functions
async def start_kanban_dispatcher(max_concurrent: int = 3, poll_interval: float = 5.0):
@@ -9,7 +9,6 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
import logging
import math
import os
@@ -4637,29 +4636,17 @@ class WebSocketGateway:
except Exception:
pass
def _kwargs_for(fn: Any) -> Dict[str, Any]:
# Issue #5884: decide the calling convention *before* invoking, so
# an application ``TypeError`` raised inside the entry point is
# never mistaken for a missing ``cancel_token`` and replayed.
if not _kw:
return _kw
try:
params = inspect.signature(fn).parameters.values()
except (TypeError, ValueError):
return _kw
if any(
p.name == "cancel_token" or p.kind is inspect.Parameter.VAR_KEYWORD
for p in params
):
return _kw
# Entry point predates cancel_token: fall back to the shared
# attribute for this turn (best-effort, non-isolated).
if hasattr(agent, "interrupt_controller"):
agent.interrupt_controller = interrupt
return {}
async def _call_async(fn: Any) -> Any:
result = await fn(content, **_kwargs_for(fn))
try:
result = await fn(content, **_kw)
except TypeError:
if not _kw:
raise
# Entry point predates cancel_token: fall back to the shared
# attribute for this turn (best-effort, non-isolated).
if hasattr(agent, "interrupt_controller"):
agent.interrupt_controller = interrupt
result = await fn(content)
# No ``await`` between the turn completing and the snapshot: the
# single-threaded loop cannot run another turn in between.
_snapshot()
@@ -4673,7 +4660,14 @@ class WebSocketGateway:
loop = asyncio.get_running_loop()
def _call_sync() -> Any:
result = agent.chat(content, **_kwargs_for(agent.chat))
try:
result = agent.chat(content, **_kw)
except TypeError:
if not _kw:
raise
if hasattr(agent, "interrupt_controller"):
agent.interrupt_controller = interrupt
result = agent.chat(content)
# Snapshot in *this* worker thread, immediately after the turn
# produced its result, before the thread returns to the pool and a
# concurrent turn can overwrite the agent's shared metrics.
@@ -7686,14 +7680,6 @@ class WebSocketGateway:
return None
cfg = (self._loaded_config or {}).get("scheduler", {}) or {}
if not isinstance(cfg, dict):
# A malformed (e.g. hot-reloaded) ``scheduler:`` block must not
# kill the tick loop — fall back to the fail-closed defaults.
logger.warning(
"Ignoring non-mapping 'scheduler:' config (%s); using defaults",
type(cfg).__name__,
)
cfg = {}
denied = cfg.get("denied_toolsets")
denied_toolsets = (
set(denied) if isinstance(denied, (list, tuple, set))
@@ -7770,33 +7756,16 @@ class WebSocketGateway:
# keeps a long-lived gateway from pinning stale unattended-run
# settings. Falls back to ``run_loop`` when the executor predates
# per-tick policy refresh.
# A tick that raises (store I/O such as EMFILE on the lock file, a
# bad config) is logged and retried with capped backoff instead of
# ending the task — otherwise scheduling would silently stop for
# good while chat channels keep the gateway looking healthy.
failures = 0
try:
while True:
try:
executor._run_policy = self._build_run_policy()
await executor.tick_all()
failures = 0
except asyncio.CancelledError:
raise
except Exception:
failures += 1
logger.exception(
"Scheduler tick failed (%d in a row)", failures,
)
await asyncio.sleep(
min(interval * 2 ** min(failures, 10), 300.0) if failures
else interval
)
executor._run_policy = self._build_run_policy()
await executor.tick_all()
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
self._scheduler_task = asyncio.create_task(_run())
logger.info("Scheduler tick started (interval=%ss)", interval)
logger.info("Scheduler tick started (interval=15s)")
async def _start_session_cleanup(self) -> None:
"""Start periodic cleanup of expired sessions."""
@@ -1094,8 +1094,7 @@ class SQLiteKanbanStore:
with self._get_connection() as conn:
cursor = conn.execute("""
SELECT id, claim_lock, claim_expires, worker_pid, last_heartbeat_at, version,
current_run_id
SELECT id, claim_lock, claim_expires, worker_pid, last_heartbeat_at, version
FROM tasks
WHERE status = 'running' AND claim_lock IS NOT NULL AND claim_lock != ''
""")
@@ -1128,7 +1127,7 @@ class SQLiteKanbanStore:
result = conn.execute("""
UPDATE tasks
SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL,
last_heartbeat_at = NULL, current_run_id = NULL,
last_heartbeat_at = NULL,
updated_at = ?, status = 'ready', version = version + 1
WHERE id = ? AND status = 'running' AND claim_lock = ?
AND version = ?
@@ -1137,20 +1136,6 @@ class SQLiteKanbanStore:
if result.rowcount > 0:
reclaimed.append(row['id'])
reason = 'dead' if not worker_alive else 'stale_heartbeat'
# Close the abandoned attempt in the same transaction, so a
# dispatcher that claims the task next can never have its
# own fresh run mistaken for this one.
if row.get('current_run_id') is not None and conn.execute(
"UPDATE task_runs SET outcome = ?, error = ?, ended_at = ? "
"WHERE id = ? AND ended_at IS NULL",
(RunOutcome.CRASHED.value,
f'Worker {reason}; claim reclaimed',
now.isoformat(), row['current_run_id']),
).rowcount:
self._log_event(conn, row['id'], 'run_closed', {
'run_id': row['current_run_id'],
'outcome': RunOutcome.CRASHED.value,
})
reclaim_details[row['id']] = {
'worker_id': row['claim_lock'],
'worker_pid': pid,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-bot"
version = "0.1.18"
version = "0.1.15"
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
@@ -417,50 +417,3 @@ def test_discord_media_transient_error_propagates_for_retry(tmp_path):
ok = asyncio.run(_run())
assert ok is True
assert channel.calls == 2
def test_discord_media_thread_is_scoped_to_parent_channel(tmp_path):
# #6084: the thread segment of "discord:A:B" must not redirect the upload
# to an arbitrary channel B by global id — only a thread under the
# (attested) parent A may receive it, otherwise the file lands in A.
pytest.importorskip("discord")
f = tmp_path / "report.pdf"
f.write_bytes(b"%PDF-1.4 data")
class _Channel:
def __init__(self, threads=None):
self.sent = 0
self._threads = threads or {}
async def send(self, *args, **kwargs):
self.sent += 1
def get_thread(self, tid):
return self._threads.get(tid)
real_thread = _Channel()
parent = _Channel(threads={555: real_thread})
unrelated = _Channel()
channels = {42: parent, 999: unrelated}
class Adapter:
platform = "discord"
class _client:
@staticmethod
def get_channel(cid):
return channels.get(cid)
def _run(thread_id):
return asyncio.run(
deliver_media_to_adapter(Adapter(), "42", str(f), thread_id=thread_id)
)
assert _run("999") is True
assert unrelated.sent == 0
assert parent.sent == 1
assert _run("555") is True
assert real_thread.sent == 1
assert parent.sent == 1
@@ -88,34 +88,6 @@ def test_send_to_platform_home_channel():
assert sent == [("999", "Nightly summary")]
def test_send_to_unattested_explicit_chat_is_refused():
# #6084: a model-supplied "<platform>:<chat_id>" naming a chat the gateway
# has no relationship with must fail closed instead of delivering.
router, sent = _make_router(home="999")
origin = SessionSource(platform="telegram", channel_id="123")
messenger = BotOutboundMessenger(router, origin=origin)
result = asyncio.run(messenger.send("telegram:666", "leak"))
assert result.ok is False
assert "Refused" in result.summary
assert sent == []
def test_send_to_known_explicit_chat_is_allowed():
router, sent = _make_router(home="999", aliases={"ops": "555"})
origin = SessionSource(platform="telegram", channel_id="123")
messenger = BotOutboundMessenger(router, origin=origin)
for target, chat in (
("telegram:123", "123"), # origin chat
("telegram:999", "999"), # home channel
("telegram:555:42", "555"), # aliased chat, thread under it
):
assert asyncio.run(messenger.send(target, "hi")).ok is True, target
assert [c for c, _ in sent] == ["123", "999", "555"]
def test_send_unresolvable_target_fails_cleanly():
router, sent = _make_router()
messenger = BotOutboundMessenger(router) # no origin
@@ -141,100 +141,53 @@ async def test_slack_stream_uploads_media(monkeypatch, tmp_path):
@pytest.mark.asyncio
async def test_discord_stream_chunks_long_reply():
"""A streamed answer over Discord's cap is chunked by the shared streamer.
async def test_discord_stream_chunks_long_reply(monkeypatch):
"""A streamed answer over Discord's cap is delivered via the chunked path.
Editing the single placeholder with >2000 chars would 400 and drop the
whole reply, so ``finalize`` edits the placeholder with the first chunk and
sends the remainder as follow-ons (Issue #6078).
Guards Greptile #3/#5: editing the single placeholder with >2000 chars
would 400 and drop the whole reply, so the finalize path must fall back to
the reference-preserving ``_send_long_message`` chunker.
"""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
long_reply = "x" * 5000
bot._session.chat = AsyncMock(return_value=long_reply)
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
bot.edit_message = AsyncMock(return_value=None)
bot.delete_message = AsyncMock(return_value=True)
sent_chunks = []
long_sender = AsyncMock(
side_effect=lambda channel, text, reference=None: sent_chunks.append(text)
)
monkeypatch.setattr(bot, "_send_long_message", long_sender)
from praisonai_bot.bots._streaming import DraftStreamer
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
bot.edit_message = AsyncMock(return_value=None)
streamer = DraftStreamer(
adapter=bot, channel_id="C1", config=bot._streaming_config, platform="discord",
adapter=bot,
channel_id="C1",
config=bot._streaming_config,
platform="discord",
)
await streamer.start()
long_reply = "\n\n".join(f"Paragraph {i}. " + "x" * 900 for i in range(6))
await streamer.finalize(long_reply)
# Simulate the finalize decision the adapter makes for an over-cap reply.
send_result = bot.fire_message_sending("C1", long_reply)
final_content = send_result["content"]
cap = min(bot.config.max_message_length, 2000)
assert len(final_content) > cap
edited = bot.edit_message.await_args.args[2]
follow_ons = [c.args[1] for c in bot.send_message.await_args_list[1:]]
assert edited.startswith("Paragraph 0.")
assert follow_ons, "remainder should be sent as follow-on messages"
assert all(len(c) <= 2000 for c in [edited, *follow_ons])
assert "".join([edited, *follow_ons]).replace("\n", "") == long_reply.replace("\n", "")
message = MagicMock()
message.channel = MagicMock()
if len(final_content) > cap:
await bot.delete_message("C1", "m1")
await bot._send_long_message(message.channel, final_content, reference=message)
else: # pragma: no cover - defensive
await streamer.finalize(final_content)
@pytest.mark.asyncio
async def test_streamer_chunks_by_utf16_and_falls_back_per_chunk():
"""Telegram-style UTF-16 cap: over-cap reply is chunked, never dropped.
When the placeholder edit fails, the first chunk falls back to a fresh
send, and every chunk stays within the UTF-16 limit (Issue #6078).
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from praisonai_bot.bots._chunk import _calculate_length
from praisonai_bot.bots._streaming import DraftStreamer, StreamingConfig
adapter = SimpleNamespace(
capabilities={"live_edit": True, "text_limit": 100},
platform_capabilities=SimpleNamespace(length_unit="utf16"),
send_message=AsyncMock(return_value={"message_id": "m1"}),
edit_message=AsyncMock(side_effect=RuntimeError("message is too long")),
)
streamer = DraftStreamer(
adapter=adapter, channel_id="C1",
config=StreamingConfig(mode=StreamingMode.DRAFT), platform="telegram",
)
await streamer.start()
reply = "\U0001F600" * 120 # 240 UTF-16 units, 120 codepoints
await streamer.finalize(reply)
sent = [c.args[1] for c in adapter.send_message.await_args_list[1:]]
assert len(sent) >= 3
assert all(_calculate_length(c, "utf16") <= 100 for c in sent)
assert "".join(sent) == reply
@pytest.mark.asyncio
async def test_streamer_follow_on_not_double_retried():
"""Adapters already retry + park exhausted sends; the streamer must not
retry again, or a parked chunk would be re-sent and duplicated (#6078)."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from praisonai_bot.bots._streaming import DraftStreamer, StreamingConfig
send = AsyncMock(side_effect=[{"message_id": "m1"}, ConnectionError("parked"), None])
adapter = SimpleNamespace(
capabilities={"live_edit": True, "text_limit": 10},
send_message=send,
edit_message=AsyncMock(return_value=None),
)
streamer = DraftStreamer(
adapter=adapter, channel_id="C1",
config=StreamingConfig(mode=StreamingMode.DRAFT), platform="telegram",
)
await streamer.start()
await streamer.finalize("a" * 30)
# placeholder + exactly one attempt per follow-on chunk (2 chunks)
assert send.await_count == 3
def test_fit_prefix_utf16_is_bounded():
from praisonai_bot.bots._streaming import _fit_prefix
assert _fit_prefix("\U0001F600" * 5, 5, "utf16") == "\U0001F600" * 2
assert _fit_prefix("abc", 10, "utf16") == "abc"
assert _fit_prefix("abcdef", 3, "codepoints") == "abc"
assert sent_chunks, "long reply should have been routed through the chunker"
bot.delete_message.assert_awaited()
@@ -1,167 +0,0 @@
"""Per-channel config lifecycle: add / update / disable / remove (Issue #6079)."""
import pytest
import yaml
from typer.testing import CliRunner
from praisonai_bot.cli.commands.gateway import app
from praisonai_bot.gateway.admin import edit_gateway_channel
BASE = {
"agents": {"assistant": {"instructions": "hi"}},
"channels": {
"telegram": {"platform": "telegram", "token": "${TELEGRAM_BOT_TOKEN}"},
},
}
@pytest.fixture
def config(tmp_path):
path = tmp_path / "gateway.yaml"
path.write_text(yaml.safe_dump(BASE, sort_keys=False))
return path
def _load(path):
return yaml.safe_load(path.read_text())
def _run(*args):
return CliRunner().invoke(app, ["channels", *args])
def test_add_channel_keeps_others_and_defaults_env_token(config):
result = _run("add", "discord", "--config", str(config), "--set", "group_policy=mention_only")
assert result.exit_code == 0, result.output
channels = _load(config)["channels"]
assert channels["telegram"] == BASE["channels"]["telegram"]
assert channels["discord"] == {
"platform": "discord",
"token": "${DISCORD_BOT_TOKEN}",
"group_policy": "mention_only",
}
assert _load(config)["agents"] == BASE["agents"]
def test_add_existing_channel_is_rejected(config):
result = _run("add", "telegram", "--config", str(config))
assert result.exit_code == 1
assert _load(config) == BASE
def test_update_and_disable_channel(config):
assert _run("update", "telegram", "--config", str(config), "--token", "${NEW_TOKEN}").exit_code == 0
assert _run("add", "discord", "--config", str(config)).exit_code == 0
assert _run("disable", "telegram", "--config", str(config)).exit_code == 0
telegram = _load(config)["channels"]["telegram"]
assert telegram["token"] == "${NEW_TOKEN}"
assert telegram["enabled"] is False
def test_invalid_update_is_not_persisted(config):
result = _run("update", "telegram", "--config", str(config), "--set", "allowed_users=[1, 2]")
assert result.exit_code == 1
assert _load(config) == BASE
def test_unknown_platform_is_rejected(config):
assert _run("add", "bogus", "--config", str(config)).exit_code == 1
assert _load(config) == BASE
def test_remove_channel(config):
assert _run("add", "discord", "--config", str(config)).exit_code == 0
assert _run("remove", "discord", "--config", str(config)).exit_code == 0
assert "discord" not in _load(config)["channels"]
assert _run("remove", "discord", "--config", str(config)).exit_code == 1
def test_python_helper_matches_cli(config):
block = edit_gateway_channel(config, "discord", {"token": "${D}"})
assert block == {"platform": "discord", "token": "${D}"}
with pytest.raises(KeyError):
edit_gateway_channel(config, "nope", remove=True)
with pytest.raises(ValueError):
edit_gateway_channel(config, "bogus", {})
assert "bogus" not in _load(config)["channels"]
def test_list_still_works_without_subcommand(config):
result = _run("--config", str(config), "--json")
assert result.exit_code == 0
assert '"telegram"' in result.output
def test_parent_config_option_applies_to_subcommand(config, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
other = tmp_path / "other.yaml"
other.write_text(yaml.safe_dump(BASE, sort_keys=False))
result = _run("--config", str(other), "update", "telegram", "--set", "group_policy=mention_only")
assert result.exit_code == 0, result.output
assert _load(other)["channels"]["telegram"]["group_policy"] == "mention_only"
assert _load(config) == BASE
def test_single_bot_platform_is_converted_not_hidden(tmp_path):
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "telegram", "token": "${T}"}))
edit_gateway_channel(path, "discord", {"token": "${D}"})
cfg = _load(path)
assert "platform" not in cfg and "token" not in cfg
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
assert cfg["channels"]["discord"]["token"] == "${D}"
def test_botos_platforms_are_converted(tmp_path):
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platforms": {"telegram": {"token": "${T}"}}}))
result = CliRunner().invoke(app, ["channels", "update", "telegram", "--config", str(path), "--set", "group_policy=mention_only"])
assert result.exit_code == 0, result.output
cfg = _load(path)
assert "platforms" not in cfg
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}", "group_policy": "mention_only"}
def test_env_placeholder_in_typed_field_does_not_block_edit(config, monkeypatch):
cfg = _load(config)
cfg["channels"]["telegram"]["max_inbound_media_bytes"] = "${MEDIA_LIMIT}"
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
monkeypatch.setenv("MEDIA_LIMIT", "1024")
edit_gateway_channel(config, "telegram", {"token": "${NEW}"})
saved = _load(config)["channels"]["telegram"]
assert saved["max_inbound_media_bytes"] == "${MEDIA_LIMIT}"
assert saved["token"] == "${NEW}"
def test_removing_last_channel_is_rejected_even_with_env_token(config, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
with pytest.raises(ValueError):
edit_gateway_channel(config, "telegram", remove=True)
with pytest.raises(ValueError):
edit_gateway_channel(config, "telegram", {"enabled": False})
assert _load(config) == BASE
def test_env_platform_reference_is_resolved_before_conversion(tmp_path, monkeypatch):
monkeypatch.setenv("BOT_PLATFORM", "telegram")
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "${BOT_PLATFORM}", "token": "${T}"}))
edit_gateway_channel(path, "discord", {"token": "${D}"})
cfg = _load(path)
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
assert cfg["channels"]["discord"]["token"] == "${D}"
def test_concurrent_edits_are_not_lost(config):
from concurrent.futures import ThreadPoolExecutor
names = [f"ch{i}" for i in range(8)]
with ThreadPoolExecutor(len(names)) as pool:
list(pool.map(lambda n: edit_gateway_channel(config, n, {"platform": "telegram", "token": "${X}"}), names))
assert set(_load(config)["channels"]) == {"telegram", *names}
def test_missing_config_raises_without_lock_file(tmp_path):
with pytest.raises(FileNotFoundError):
edit_gateway_channel(tmp_path / "nope.yaml", "discord", {})
assert not list(tmp_path.iterdir())
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""Issue #5884: ``_dispatch_agent_turn`` must not replay a turn whose entry
point raised an internal ``TypeError``; legacy entry points without
``cancel_token`` are detected from the signature before invoking."""
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[5]
sys.path.insert(0, str(REPO_ROOT / "src" / "praisonai-agents"))
from praisonai_bot.gateway.server import WebSocketGateway
_dispatch = WebSocketGateway._dispatch_agent_turn
_TOKEN = object()
class _FailingSync:
interrupt_controller = None
def __init__(self):
self.calls = []
def chat(self, content, cancel_token=None):
self.calls.append(cancel_token)
raise TypeError("application failure")
class _FailingAsync(_FailingSync):
async def achat(self, content, cancel_token=None):
self.calls.append(cancel_token)
raise TypeError("application failure")
class _LegacySync:
interrupt_controller = None
def __init__(self):
self.calls = 0
def chat(self, content):
self.calls += 1
return f"sync:{content}"
class _LegacyAsync(_LegacySync):
async def achat(self, content):
self.calls += 1
return f"async:{content}"
class _KwargsAsync:
async def achat(self, content, **kwargs):
return kwargs.get("cancel_token")
@pytest.mark.asyncio
@pytest.mark.parametrize("cls", [_FailingSync, _FailingAsync])
async def test_internal_type_error_is_not_replayed(cls):
agent = cls()
with pytest.raises(TypeError, match="application failure"):
await _dispatch(agent, "hi", interrupt=_TOKEN)
assert agent.calls == [_TOKEN]
assert agent.interrupt_controller is None
@pytest.mark.asyncio
@pytest.mark.parametrize("cls,prefix", [(_LegacySync, "sync"), (_LegacyAsync, "async")])
async def test_legacy_signature_uses_shared_controller(cls, prefix):
agent = cls()
assert await _dispatch(agent, "hi", interrupt=_TOKEN) == f"{prefix}:hi"
assert agent.calls == 1
assert agent.interrupt_controller is _TOKEN
@pytest.mark.asyncio
async def test_var_keyword_receives_cancel_token():
assert await _dispatch(_KwargsAsync(), "hi", interrupt=_TOKEN) is _TOKEN
@@ -378,51 +378,3 @@ def test_gateway_backend_forwards_authorized_reviewers(tmp_path):
assert decision.approved is True
asyncio.run(go())
def test_allowlist_hot_path_enforces_grant_ttl(tmp_path, monkeypatch):
"""An allow-always grant expires in memory without a process restart."""
import time as _time
from praisonai_bot.gateway.exec_approval import ScopedAllowlistStore
store = ScopedAllowlistStore(path=tmp_path / "allow.sqlite", ttl_seconds=60)
al = PermissionAllowlist(store=store)
al.add_scoped(agent_id="a1", tool_name="shell_exec", arg_signature="sig")
assert al.allows(agent_id="a1", tool_name="shell_exec", arg_signature="sig")
now = _time.time()
monkeypatch.setattr(_time, "time", lambda: now + 61)
assert not al.allows(agent_id="a1", tool_name="shell_exec", arg_signature="sig")
assert al.list_scoped() == []
def test_allowlist_in_memory_grant_ttl(monkeypatch):
"""Non-durable grants honour the default 90-day cap too."""
import time as _time
al = PermissionAllowlist(durable=False)
al.add("shell_exec")
assert "shell_exec" in al
now = _time.time()
monkeypatch.setattr(_time, "time", lambda: now + 91 * 86400)
assert "shell_exec" not in al
assert al.list() == []
def test_allowlist_rehydrate_keeps_original_grant_age(tmp_path, monkeypatch):
"""Rehydrated grants keep their persisted created_at, not restart time."""
import time as _time
from praisonai_bot.gateway.exec_approval import ScopedAllowlistStore
path = tmp_path / "allow.sqlite"
PermissionAllowlist(store=ScopedAllowlistStore(path=path, ttl_seconds=60)).add(
"shell_exec"
)
now = _time.time()
monkeypatch.setattr(_time, "time", lambda: now + 30)
al = PermissionAllowlist(store=ScopedAllowlistStore(path=path, ttl_seconds=60))
assert "shell_exec" in al
monkeypatch.setattr(_time, "time", lambda: now + 61)
assert "shell_exec" not in al
@@ -1,167 +0,0 @@
"""Regression tests for #6025: the dispatcher heartbeats its own children.
A healthy worker that outlives the claim lease + stale timeout must not be
reclaimed and re-dispatched while it is still running.
"""
import asyncio
import subprocess
import sys
import pytest
from praisonai_bot.gateway.kanban_dispatcher import KanbanDispatcher
from praisonai_bot.kanban.sqlite_store import SQLiteKanbanStore
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="uses sleep stub")
class _StubDispatcher(KanbanDispatcher):
def __init__(self, store, seconds, **kwargs):
super().__init__(**kwargs)
self._store = store
self._seconds = seconds
def _get_kanban_store(self):
return self._store
def _build_execution_command(self, task):
return [sys.executable, "-c", f"import time; time.sleep({self._seconds})"]
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_KANBAN_DB", str(tmp_path / "kanban.db"))
return SQLiteKanbanStore()
def _run(coro):
return asyncio.run(coro)
def test_long_running_worker_is_not_reclaimed(store):
store.create_task({"id": "t1", "title": "long job", "status": "ready",
"workspace_kind": "default"})
d = _StubDispatcher(store, 5, max_concurrent=3, poll_interval=0.5,
claim_ttl_seconds=1, stale_timeout_seconds=2)
async def drive():
handles = []
loop = asyncio.get_running_loop()
t0 = loop.time()
while loop.time() - t0 < 4.5:
await d.dispatch_once()
proc = d.running_tasks.get("t1")
if proc is not None and proc not in handles:
handles.append(proc)
await asyncio.sleep(0.25)
return handles
handles = _run(drive())
try:
assert len(handles) == 1
runs = store.get_runs("t1")
assert len(runs) == 1
assert runs[0].ended_at is None
assert not [e for e in store.list_events()
if e.task_id == "t1" and e.event_type == "reclaimed"]
handles[0].wait(timeout=10)
_run(d.dispatch_once())
runs = store.get_runs("t1")
assert len(runs) == 1
assert runs[0].outcome == "completed"
assert store.get_task("t1").status == "done"
finally:
for p in handles:
if p.poll() is None:
p.kill()
def test_lost_claim_terminates_child(store):
store.create_task({"id": "t2", "title": "job", "status": "ready",
"workspace_kind": "default"})
d = _StubDispatcher(store, 30, max_concurrent=1, poll_interval=0.5)
_run(d.dispatch_once())
proc = d.running_tasks["t2"]
try:
store.release_claim("t2", d.worker_id) # operator takes the card back
d._heartbeat_children(store)
assert "t2" not in d.running_tasks
assert proc.wait(timeout=10) is not None
runs = store.get_runs("t2")
assert len(runs) == 1 and runs[0].outcome == "failed"
finally:
if proc.poll() is None:
proc.kill()
def test_reclaim_of_dead_worker_closes_run_and_counts_failure(store):
store.create_task({"id": "t3", "title": "job", "status": "ready",
"workspace_kind": "default"})
# Simulate another dispatcher whose worker has died.
assert store.claim_task("t3", "other", ttl_seconds=0, worker_pid=0)
run_id = store.start_run("t3", profile="other")
d = KanbanDispatcher(claim_ttl_seconds=1, stale_timeout_seconds=1)
d._reclaim_stale_claims(store)
run = [r for r in store.get_runs("t3") if r.id == run_id][0]
assert run.outcome == "crashed"
assert run.ended_at
assert store.get_task("t3").consecutive_failures == 1
def test_live_handle_is_never_overwritten(store):
store.create_task({"id": "t4", "title": "job", "status": "ready",
"workspace_kind": "default"})
d = _StubDispatcher(store, 30, max_concurrent=2)
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
d.running_tasks["t4"] = proc
d._heartbeat_children = lambda _store: None # isolate the spawn guard
try:
assert _run(d.dispatch_once()) == 0
assert d.running_tasks["t4"] is proc
finally:
proc.kill()
proc.wait()
def test_reclaim_closes_run_atomically_so_replacement_run_survives(store):
store.create_task({"id": "t5", "title": "job", "status": "ready",
"workspace_kind": "default"})
assert store.claim_task("t5", "other", ttl_seconds=0, worker_pid=0)
old_run = store.start_run("t5", profile="other")
assert store.reclaim_stale_claims(stale_timeout_seconds=1) == ["t5"]
assert store.get_task("t5").current_run_id is None
assert any(e.event_type == "run_closed" and e.task_id == "t5"
for e in store.list_events())
# Another dispatcher wins the reclaimed task before ours reacts.
assert store.claim_task("t5", "rival", ttl_seconds=60)
new_run = store.start_run("t5", profile="rival")
KanbanDispatcher()._reclaim_stale_claims(store)
runs = {r.id: r for r in store.get_runs("t5")}
assert runs[old_run].outcome == "crashed"
assert runs[new_run].ended_at is None
def test_lost_claim_keeps_worktree_tracking_when_work_is_preserved(store):
store.create_task({"id": "t6", "title": "job", "status": "ready",
"workspace_kind": "default"})
d = _StubDispatcher(store, 30, max_concurrent=1)
_run(d.dispatch_once())
proc = d.running_tasks["t6"]
d._worktrees = {"t6": ("/tmp/wt-t6", "kanban/t6")}
d._remove_worktree = lambda path, branch: "worktree has uncommitted changes"
try:
store.release_claim("t6", d.worker_id)
d._heartbeat_children(store)
assert "t6" not in d.running_tasks
assert d._worktrees == {"t6": ("/tmp/wt-t6", "kanban/t6")}
assert any("worktree_preserved" in c.text for c in store.get_comments("t6"))
finally:
if proc.poll() is None:
proc.kill()
@@ -1,136 +0,0 @@
"""The kanban dispatcher must not run blocking git work on the event loop."""
import asyncio
import itertools
import os
import subprocess
import sys
import threading
import time
import types
import pytest
import praisonai_bot.gateway.kanban_dispatcher as kd
from praisonai_bot.kanban.sqlite_store import SQLiteKanbanStore
GIT_DELAY = 0.5
class _Stub(kd.KanbanDispatcher):
def _build_execution_command(self, task):
return ["true"]
async def _heartbeat(gaps, stop):
last = time.monotonic()
while not stop.is_set():
await asyncio.sleep(0.05)
now = time.monotonic()
gaps.append(now - last)
last = now
@pytest.fixture
def slow_git(tmp_path, monkeypatch):
"""Patch ``run_guarded`` with a synchronous sleep, recording overlap."""
monkeypatch.setenv("PRAISONAI_KANBAN_DB", str(tmp_path / "kanban.db"))
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
state = {"active": 0, "max_active": 0, "calls": []}
lock = threading.Lock()
def fake(cmd, *, timeout=None, cwd=None, **kw):
with lock:
state["active"] += 1
state["max_active"] = max(state["max_active"], state["active"])
state["calls"].append(" ".join(cmd[:3]))
try:
time.sleep(GIT_DELAY)
if list(cmd[1:3]) == ["worktree", "add"]:
os.makedirs(cmd[3], exist_ok=True)
out = {"rev-parse": "main\n", "rev-list": "0\n"}.get(cmd[1], "")
return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="")
finally:
with lock:
state["active"] -= 1
monkeypatch.setattr(kd, "run_guarded", fake)
return state
@pytest.mark.skipif(sys.platform == "win32", reason="uses the POSIX `true` worker")
async def test_dispatch_does_not_block_event_loop(slow_git):
store = SQLiteKanbanStore()
store.create_task({"id": "w1", "title": "isolated", "status": "ready",
"workspace_kind": "worktree"})
d = _Stub(max_concurrent=1)
gaps, stop = [], asyncio.Event()
hb = asyncio.create_task(_heartbeat(gaps, stop))
await asyncio.sleep(0.1)
assert await d.dispatch_once() == 1 # spawn: rev-parse + worktree add
await asyncio.to_thread(d.running_tasks["w1"].wait)
await d.dispatch_once() # completion: commit/merge/cleanup
await asyncio.sleep(0.1)
stop.set()
await hb
assert "git worktree add" in slow_git["calls"]
assert "git merge --no-ff" in slow_git["calls"]
assert max(gaps) < 0.3, f"event loop stalled for {max(gaps):.2f}s"
async def test_blocking_work_is_serialised(slow_git):
a, b = _Stub(), _Stub()
await asyncio.gather(
a._run_blocking(a._run_git, "status"),
b._run_blocking(b._run_git, "status"),
)
assert slow_git["max_active"] == 1
async def test_cancel_during_worktree_prep_cleans_up(slow_git):
store = SQLiteKanbanStore()
store.create_task({"id": "w1", "title": "isolated", "status": "ready",
"workspace_kind": "worktree"})
d = _Stub(max_concurrent=1)
t = asyncio.create_task(d.dispatch_once())
await asyncio.sleep(GIT_DELAY + 0.1) # inside `git worktree add`
t.cancel()
with pytest.raises(asyncio.CancelledError):
await t
assert "git worktree remove" in slow_git["calls"]
assert "git branch -D" in slow_git["calls"]
assert not d.running_tasks
assert store.get_task("w1").status == "ready"
assert all(r.ended_at for r in store.get_runs("w1"))
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
async def test_shutdown_terminates_worker_descendants(tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_KANBAN_DB", str(tmp_path / "kanban.db"))
marker = tmp_path / "survived"
class _Spawner(kd.KanbanDispatcher):
def _build_execution_command(self, task):
# Worker exits on SIGTERM, but its child ignores SIGTERM and
# would leave a marker if it outlived shutdown.
return ["sh", "-c",
f"(trap '' TERM; sleep 1; touch {marker}) & wait"]
class _Task:
id, board, title, body = "t1", "default", "t1", ""
d = _Spawner()
assert await d._spawn_worker(_Task(), store=None)
await asyncio.sleep(0.2)
# Skip the 30 s graceful window; go straight to force-terminate.
ticks = itertools.count(step=1000)
monkeypatch.setattr(kd, "time", types.SimpleNamespace(time=lambda: next(ticks)))
await d._shutdown()
await asyncio.sleep(1.5)
assert not marker.exists(), "worker's descendant survived shutdown"
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more