You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/praisonai-agents/praisonaiagents only (package praisonaiagents). Out of scope by design: documentation, tests, coverage, file size/line count, and generic performance/robustness suggestions — these are three functional gaps where an async code path silently fails to do what its sync sibling (or its own documented contract) does.
Every snippet below was read directly from the current branch (not paraphrased from a research pass) and cross-checked against prior closed audit issues to rule out duplicates — see the "Prior-art check" under each finding.
1. Async guardrail validation makes a blocking, synchronous LLM call from inside async def methods — it stalls the whole event loop, not just the one agent
LLMGuardrail (the built-in implementation used whenever Agent(guardrail="some description") is used, and by GuardrailChain) implements those with a blocking network call:
# guardrails/llm_guardrail.py:114-120 (_validate, used by validate_output)ifhasattr(self.llm, 'chat'):
response=self.llm.chat(validation_prompt, temperature=0.1)
elifhasattr(self.llm, 'get_response'):
response=self.llm.get_response(validation_prompt, temperature=0.1)
# guardrails/llm_guardrail.py:229-237 (_llm_validate, used by validate_input/validate_tool_call)ifhasattr(self.llm, 'get_response'):
response=self.llm.get_response(prompt=prompt, verbose=False, markdown=False, stream=False)
Both are plain def, no await, no executor. The async guardrail-retry helper calls this synchronous chain directly, unawaited, from inside its own async def:
_validate_with_guardrail (agent/agent.py:6614-6638) calls self._process_guardrail(task_output) which invokes self._guardrail_fn(task_output) — for a string/LLMGuardrail guardrail this is exactly the blocking get_response/chat call above.
The multi-agent async entry points (arun_task, driven under asyncio.gather for concurrent tasks, and astart) go through the identical synchronous chain via _apply_task_guardrail:
_apply_task_guardrail (agents/agents.py:1283-1326) calls task._process_guardrail(task_output) → same blocking LLMGuardrail chain.
The team already knows this exact hazard class — but only closed half of it. agent/agent.py:6481-6489 has this comment while constructing a string guardrail:
# agent/agent.py:6481-6489# A string guardrail is an output-quality validator. Although the# resulting LLMGuardrail also exposes ``validate_input`` (via# GuardrailProtocol), it must NOT be invoked as an input gate here:# doing so would fire an extra, synchronous LLM call on every# chat()/achat() (a hot-path regression, and blocking on the async# event loop). Mark it so input validation skips it.self._guardrail_fn._praison_output_only=True
This only prevents the input-side gate from firing an extra blocking call. The output-side gate — the primary, unavoidable use of every guardrail, string or object — still runs the identical blocking call from _aapply_guardrail_with_retry/arun_task/astart, and a directly-constructed LLMGuardrail/GuardrailChain instance (the callable(self.guardrail) branch at agent/agent.py:6433) never gets _praison_output_only set at all, so it isn't even partially covered.
Concrete failure scenario
importasynciofrompraisonaiagentsimportAgent, PraisonAIAgents, Taskdefslow_but_valid(x): return"ok "+xagent1=Agent(name="A", instructions="Echo the input.", guardrail="Output must be under 20 words")
agent2=Agent(name="B", instructions="Echo the input.")
t1=Task(description="respond", agent=agent1)
t2=Task(description="respond quickly", agent=agent2)
# Both tasks run concurrently under asyncio.gather() inside arun_all_tasks/astart.# Task 1's guardrail check fires a *synchronous* LLM call inside an async method,# so the event loop is blocked for that call's full latency — Task 2 (and any# other concurrently-running agent/task in the same process) makes zero progress# until Task 1's guardrail LLM round trip completes, even though Task 2 has# nothing to do with Task 1's guardrail.PraisonAIAgents(agents=[agent1, agent2], tasks=[t1, t2]).astart()
Suggested fix
Add avalidate_input/avalidate_output/avalidate_tool_call to GuardrailProtocol with a default that offloads the sync method via loop.run_in_executor (the same pattern already used correctly elsewhere in this file — see agent/async_memory_mixin.py's _run_memory_in_thread, which offloads synchronous Memory calls exactly this way). Give LLMGuardrail a real async implementation using await self.llm.aget_response(...) where available. Have _aapply_guardrail_with_retry and _apply_task_guardrail's callers (arun_task, astart) call the async surface instead of the sync one.
Prior-art check
Not a duplicate of #3907 (fixed by PR #3908, which wired up input-side validate_input never being called at all, and BEFORE_LLM/AFTER_LLM hooks not firing on async chat) — that fix did not touch _aapply_guardrail_with_retry or _apply_task_guardrail, and both still call the blocking chain today as shown above. Not a duplicate of #3573 (LLMGuardrail failing open on ambiguous replies — a correctness issue, not an event-loop-blocking issue) or #1961 (code duplication between run_task()/arun_task(), not blocking behavior).
2. Async tool execution has zero circuit-breaker protection — the sync path has it, and the async path has dead code that assumes it exists
The async tool path (_execute_tool_async_impl, the method backing every achat()/async multi-agent tool call) never touches it — confirmed today with a fresh grep, zero matches for circuit_breaker/CircuitBreaker/breaker\. anywhere in execution_mixin.py. It even carries a dead check that presupposes it was wired up:
# agent/execution_mixin.py:1563-1568 (inside _execute_tool_async_with_retry)if (result.get("approval_denied") orresult.get("permission_denied") orresult.get("approval_error") orresult.get("circuit_open") or# <- nothing on the async path ever sets this keyresult.get("loop_blocked") orresult.get("_praison_retryable") isFalse):
returnresult
Note: this is not a re-report of the loop-guard/doom-loop gap from #4000 — that was fixed by merged PR #4005, and execution_mixin.py does now call loop_guard.check()/.record() (lines 1696-1831). Only the circuit breaker itself was left out of that fix, despite #4000's own title naming "circuit breaker" as part of the gap and the merged PR's diff only adding loop-guard parity.
Concrete failure scenario
importasynciofrompraisonaiagentsimportAgentdefflaky_tool(query: str) ->str:
raiseRuntimeError("downstream service down") # always fails, e.g. a broken APIagent=Agent(name="Worker", tools=[flaky_tool])
# Sync: after repeated failures, _execute_tool_with_circuit_breaker trips the# breaker and short-circuits further calls to flaky_tool.agent.chat("Keep calling flaky_tool until it works.")
# Async: the identical agent via achat() has no circuit breaker on this path —# the LLM can retry the same failing tool call every turn indefinitely, hammering# the broken downstream service, with the "circuit_open" retry-skip at# execution_mixin.py:1566 never actually triggering.asyncio.run(agent.achat("Keep calling flaky_tool until it works."))
Suggested fix
In _execute_tool_async_impl, wrap the actual tool invocation through breaker.acall(...) using the same tool_{id(self)}_{function_name} breaker-naming/weakref-finalizer scheme the sync path already uses (agent/tool_execution.py:2205-2220), and set result["circuit_open"] = True when CircuitBreakerException is raised so the already-present (but currently dead) check at execution_mixin.py:1566 starts doing something.
Prior-art check
#4000 ("Async tool-execution loop has no circuit breaker or doom-loop guard") was closed as completed via merged PR #4005 — but that PR added only loop-guard/doom-loop parity (verified: loop_guard/GuardAction now appear throughout execution_mixin.py). Circuit-breaker parity, the other half of #4000's own title, was not part of that diff and remains completely absent today, as shown by the fresh grep above returning zero matches.
3. memory/hooks.py's HooksManager (cascade hooks for file/command/prompt events) is fully built, documented, and publicly exported — but .execute() is never called anywhere
memory/__init__.py documents and re-exports it as a first-class public feature:
# memory/__init__.py:32-HooksManager: Pre/postoperationhooks
...
# memory/__init__.py:157-165ifname=="HooksManager":
from .hooksimportHooksManagerreturnHooksManagerifname=="create_hooks_manager":
from .hooksimportcreate_hooks_managerreturncreate_hooks_manager
A repo-wide grep for .execute( calls on a HooksManager/create_hooks_manager instance, or for any of the eight event-name string literals (pre_read_code, post_write_code, pre_run_command, post_user_prompt, etc.) outside memory/hooks.py itself, returns zero matches in the entire praisonaiagents package — no file-tool, no command-execution tool, and no prompt-intake code in agent/tool_execution.py or agent/chat_mixin.py ever calls HooksManager.execute(...). This is a completely separate system from the actively-used hooks/ package (HookEvent.BEFORE_TOOL/BEFORE_LLM/etc., wired via HookRunner throughout chat_mixin.py/tool_execution.py) — easy to conflate by name, but only one of the two is live. The only consumer anywhere in the monorepo is the praisonai wrapper's praisonai hooks list/stats CLI command (praisonai/cli/legacy/subcommand_handlers.py:308-339), and that only calls hooks.get_stats() for introspection/printing — never .execute().
Concrete failure scenario
# .praisonai/hooks.json# {# "hooks": { "pre_write_code": "./scripts/block_secrets.sh" },# "enabled": true# }frompraisonaiagents.memoryimportcreate_hooks_managerhooks=create_hooks_manager(workspace_path=".")
# A user follows the module's own docstring, expecting any agent file-write# operation to run ./scripts/block_secrets.sh first and be blocked on a# non-zero exit code. Nothing in the agent/tool-execution pipeline ever calls# hooks.execute(HookEvent.PRE_WRITE_CODE, ...), so agent-driven file writes# proceed unconditionally — the configured guard script never runs, with no# error, warning, or any observable difference in behavior.
Suggested fix
Either wire HooksManager.execute() into the actual file-write/read and command-execution tool implementations and into prompt intake (mirroring how hooks/runner.py's HookRunner is already invoked at the relevant agent/tool_execution.py/agent/chat_mixin.py call sites), or remove the class/export and its docstring until it is actually wired up — the current state gives a false impression of an active safety/observability control.
Prior-art check
Not a duplicate of #3085's Gap 1 ("the entire hooks/middleware.py extension point... never executed", fixed by merged PR #3086) — that finding and fix were about hooks/middleware.py's wrap_tool_call/MiddlewareManager (passed via Agent(hooks=[...])), a different module and different mechanism from memory/hooks.py's HooksManager. A repo search for HooksManager/cascade hook/memory/hooks.py in prior issues returns no matches.
Validation notes
Every snippet above was read from the current src/praisonai-agents/praisonaiagents tree at the cited lines on this run, not carried over from a prior audit.
Finding 1 was cross-checked by tracing the full call chain from arun_task/astart/achat() down to the actual self.llm.get_response(...) call, confirming no await/executor offload exists anywhere in that chain, and by reading the _praison_output_only comment in full to confirm it covers only the input-side gate.
Finding 3 was cross-checked with a repo-wide grep for every one of the eight documented hook-event string literals and for HooksManager/create_hooks_manager outside memory/hooks.py, and by reading the one real call site in the praisonai wrapper CLI to confirm it only reads stats and never executes a hook.
Scope
src/praisonai-agents/praisonaiagentsonly (packagepraisonaiagents). Out of scope by design: documentation, tests, coverage, file size/line count, and generic performance/robustness suggestions — these are three functional gaps where an async code path silently fails to do what its sync sibling (or its own documented contract) does.Every snippet below was read directly from the current branch (not paraphrased from a research pass) and cross-checked against prior closed audit issues to rule out duplicates — see the "Prior-art check" under each finding.
1. Async guardrail validation makes a blocking, synchronous LLM call from inside
async defmethods — it stalls the whole event loop, not just the one agentFiles:
guardrails/protocols.py:37-77,guardrails/llm_guardrail.py:115-152,198-243,agent/agent.py:6614-6699,agents/agents.py:1283-1326,1399-1421,1718-1843GuardrailProtocolonly declares synchronous methods — there is noavalidate_input/avalidate_outputanywhere:LLMGuardrail(the built-in implementation used wheneverAgent(guardrail="some description")is used, and byGuardrailChain) implements those with a blocking network call:Both are plain
def, noawait, no executor. The async guardrail-retry helper calls this synchronous chain directly, unawaited, from inside its ownasync def:_validate_with_guardrail(agent/agent.py:6614-6638) callsself._process_guardrail(task_output)which invokesself._guardrail_fn(task_output)— for a string/LLMGuardrailguardrail this is exactly the blockingget_response/chatcall above.The multi-agent async entry points (
arun_task, driven underasyncio.gatherfor concurrent tasks, andastart) go through the identical synchronous chain via_apply_task_guardrail:_apply_task_guardrail(agents/agents.py:1283-1326) callstask._process_guardrail(task_output)→ same blockingLLMGuardrailchain.The team already knows this exact hazard class — but only closed half of it.
agent/agent.py:6481-6489has this comment while constructing a string guardrail:This only prevents the input-side gate from firing an extra blocking call. The output-side gate — the primary, unavoidable use of every guardrail, string or object — still runs the identical blocking call from
_aapply_guardrail_with_retry/arun_task/astart, and a directly-constructedLLMGuardrail/GuardrailChaininstance (thecallable(self.guardrail)branch atagent/agent.py:6433) never gets_praison_output_onlyset at all, so it isn't even partially covered.Concrete failure scenario
Suggested fix
Add
avalidate_input/avalidate_output/avalidate_tool_calltoGuardrailProtocolwith a default that offloads the sync method vialoop.run_in_executor(the same pattern already used correctly elsewhere in this file — seeagent/async_memory_mixin.py's_run_memory_in_thread, which offloads synchronousMemorycalls exactly this way). GiveLLMGuardraila real async implementation usingawait self.llm.aget_response(...)where available. Have_aapply_guardrail_with_retryand_apply_task_guardrail's callers (arun_task,astart) call the async surface instead of the sync one.Prior-art check
Not a duplicate of #3907 (fixed by PR #3908, which wired up input-side
validate_inputnever being called at all, andBEFORE_LLM/AFTER_LLMhooks not firing on async chat) — that fix did not touch_aapply_guardrail_with_retryor_apply_task_guardrail, and both still call the blocking chain today as shown above. Not a duplicate of #3573 (LLMGuardrail failing open on ambiguous replies — a correctness issue, not an event-loop-blocking issue) or #1961 (code duplication betweenrun_task()/arun_task(), not blocking behavior).2. Async tool execution has zero circuit-breaker protection — the sync path has it, and the async path has dead code that assumes it exists
Files:
tools/circuit_breaker.py:233-278,agent/tool_execution.py:2081-2297,agent/execution_mixin.py:1542-1859CircuitBreaker.acall()is a complete, correctly async-safe implementation:The sync tool path routes every call through it, opening the circuit after repeated failures per tool/agent:
The async tool path (
_execute_tool_async_impl, the method backing everyachat()/async multi-agent tool call) never touches it — confirmed today with a fresh grep, zero matches forcircuit_breaker/CircuitBreaker/breaker\.anywhere inexecution_mixin.py. It even carries a dead check that presupposes it was wired up:Note: this is not a re-report of the loop-guard/doom-loop gap from #4000 — that was fixed by merged PR #4005, and
execution_mixin.pydoes now callloop_guard.check()/.record()(lines 1696-1831). Only the circuit breaker itself was left out of that fix, despite #4000's own title naming "circuit breaker" as part of the gap and the merged PR's diff only adding loop-guard parity.Concrete failure scenario
Suggested fix
In
_execute_tool_async_impl, wrap the actual tool invocation throughbreaker.acall(...)using the sametool_{id(self)}_{function_name}breaker-naming/weakref-finalizer scheme the sync path already uses (agent/tool_execution.py:2205-2220), and setresult["circuit_open"] = TruewhenCircuitBreakerExceptionis raised so the already-present (but currently dead) check atexecution_mixin.py:1566starts doing something.Prior-art check
#4000 ("Async tool-execution loop has no circuit breaker or doom-loop guard") was closed as completed via merged PR #4005 — but that PR added only loop-guard/doom-loop parity (verified:
loop_guard/GuardActionnow appear throughoutexecution_mixin.py). Circuit-breaker parity, the other half of #4000's own title, was not part of that diff and remains completely absent today, as shown by the fresh grep above returning zero matches.3.
memory/hooks.py'sHooksManager(cascade hooks for file/command/prompt events) is fully built, documented, and publicly exported — but.execute()is never called anywhereFiles:
memory/hooks.py:1-45,83,222,405,memory/__init__.py:32,157-165,224-226The module documents a complete pre/post hook system, configured via
.praisonai/hooks.json, for eight events:memory/__init__.pydocuments and re-exports it as a first-class public feature:A repo-wide grep for
.execute(calls on aHooksManager/create_hooks_managerinstance, or for any of the eight event-name string literals (pre_read_code,post_write_code,pre_run_command,post_user_prompt, etc.) outsidememory/hooks.pyitself, returns zero matches in the entirepraisonaiagentspackage — no file-tool, no command-execution tool, and no prompt-intake code inagent/tool_execution.pyoragent/chat_mixin.pyever callsHooksManager.execute(...). This is a completely separate system from the actively-usedhooks/package (HookEvent.BEFORE_TOOL/BEFORE_LLM/etc., wired viaHookRunnerthroughoutchat_mixin.py/tool_execution.py) — easy to conflate by name, but only one of the two is live. The only consumer anywhere in the monorepo is thepraisonaiwrapper'spraisonai hooks list/statsCLI command (praisonai/cli/legacy/subcommand_handlers.py:308-339), and that only callshooks.get_stats()for introspection/printing — never.execute().Concrete failure scenario
Suggested fix
Either wire
HooksManager.execute()into the actual file-write/read and command-execution tool implementations and into prompt intake (mirroring howhooks/runner.py'sHookRunneris already invoked at the relevantagent/tool_execution.py/agent/chat_mixin.pycall sites), or remove the class/export and its docstring until it is actually wired up — the current state gives a false impression of an active safety/observability control.Prior-art check
Not a duplicate of #3085's Gap 1 ("the entire
hooks/middleware.pyextension point... never executed", fixed by merged PR #3086) — that finding and fix were abouthooks/middleware.py'swrap_tool_call/MiddlewareManager(passed viaAgent(hooks=[...])), a different module and different mechanism frommemory/hooks.py'sHooksManager. A repo search forHooksManager/cascade hook/memory/hooks.pyin prior issues returns no matches.Validation notes
src/praisonai-agents/praisonaiagentstree at the cited lines on this run, not carried over from a prior audit.arun_task/astart/achat()down to the actualself.llm.get_response(...)call, confirming noawait/executor offload exists anywhere in that chain, and by reading the_praison_output_onlycomment in full to confirm it covers only the input-side gate.loop_guard/GuardActionnow appear atexecution_mixin.py:1696-1831(confirming that part of Async tool-execution loop has no circuit breaker or doom-loop guard, even after the recent async-timeout fix #4000 is genuinely fixed), whilecircuit_breaker/CircuitBreakerstill return zero matches in the same file.HooksManager/create_hooks_manageroutsidememory/hooks.py, and by reading the one real call site in thepraisonaiwrapper CLI to confirm it only reads stats and never executes a hook.search_issues) before filing; the closest prior matches (Async tool-execution loop has no circuit breaker or doom-loop guard, even after the recent async-timeout fix #4000, Guardrail input-side validation, async LLM hooks, and managed-backend streaming silently diverge from their own documented contracts (praisonaiagents core) #3907, Dead middleware hooks, guardrail/retry bypass in nested workflow patterns, and unsynchronized Workflow state under concurrent runs #3085, Core SDK: LLMGuardrail still fails open on ambiguous LLM replies, FailoverManager has no locking despite being a shared credential pool, and tool-output run isolation is silently disabled by an attribute typo #3573, Core SDK: identical guardrail-validation and task-callback blocks duplicated between sync run_task() and async arun_task() #1961, Policy/guardrail enforcement silently no-ops in three independent paths (praisonaiagents core) #3631, Hierarchical process manager-id guard is dead code, async tool execution drops timeout/circuit-breaker enforcement, and GuardrailConfig policy strings are unenforced #3789) were each individually opened, read in full, and confirmed to cover a different specific mechanism or to have left this particular gap out of their merged fix, as noted under each finding.