Scope & method
In‑depth review of the wrapper layer only (src/praisonai/praisonai), evaluated against the project philosophy (async‑safe by default, 3‑way feature surface, DRY, minimal‑but‑correct API). This excludes docs/tests/coverage/file‑size concerns by design. Every finding below was validated by reading the code and (where relevant) reproducing the behaviour. Three concrete, high‑impact gaps are reported with evidence and proposed fixes.
1. The wrapper exposes no native async execution path — arun() just offloads sync work to a thread
The core SDK is async‑capable: AgentTeam/PraisonAIAgents exposes astart() (src/praisonai-agents/praisonaiagents/agents/agents.py:1206), plus arun_all_tasks() and async workflows. The wrapper never surfaces any of it.
- The adapter protocol is sync‑only — there is no
arun():
# framework_adapters/base.py:23
def run(self, config, llm_config, topic, *, tools_dict=None, ...) -> str:
...
- Every adapter calls the synchronous entry point of the underlying framework:
# framework_adapters/praisonai_adapter.py:192
response = team.start() # AgentTeam.astart() is never used
# framework_adapters/crewai_adapter.py:187
response = crew.kickoff()
# framework_adapters/autogen_adapter.py:156,250
async def run_autogen_v4_async(): ...
return run_sync(run_autogen_v4_async()) # async collapsed back to sync
AgentsGenerator.generate_crew_and_kickoff() (agents_generator.py:512) and _run_yaml_workflow() (agents_generator.py:690, workflow.start() at :739) are sync‑only; there is no agenerate_* variant.
- The public async API only thread‑offloads the sync pipeline — it does not drive the SDK's async path:
# _entrypoint.py:62
async def arun(agent_file, framework=None, *, tools=None, agent_yaml=None, cli_config=None) -> str:
import asyncio
return await asyncio.to_thread(
run, agent_file, framework, tools=tools, agent_yaml=agent_yaml, cli_config=cli_config,
)
Why it matters. A caller already inside an event loop (FastAPI, Jupyter, an outer agent) gets a worker thread per run rather than cooperative async. Concurrency is bounded by the thread pool, cancellation/timeouts don't propagate into the SDK coroutines, and the "async‑safe by default" pillar is only superficially met. The 3‑way feature surface (CLI/YAML/Python) is sync everywhere.
Proposed fix. Add a real async seam end‑to‑end and let the sync API delegate to it (single source of truth):
# framework_adapters/base.py (protocol)
async def arun(self, config, llm_config, topic, *, tools_dict=None,
agent_callback=None, task_callback=None, cli_config=None) -> str:
...
# framework_adapters/praisonai_adapter.py
async def arun(self, config, llm_config, topic, **kw) -> str:
... # build agents/tasks/team exactly as run()
response = await team.astart() # native async path
return f"### PraisonAI Output ###\n{response}" if response else "..."
# adapters that have no native async fall back transparently:
async def arun(self, *a, **k):
return await asyncio.to_thread(self.run, *a, **k)
# agents_generator.py
async def agenerate_crew_and_kickoff(self):
... # same parsing/merge logic, factored out
return await self.framework_adapter.arun(config, self.config_list, topic, ...)
# _entrypoint.py
async def arun(agent_file, framework=None, **kw) -> str:
gen = AgentsGenerator(...)
return await gen.agenerate_crew_and_kickoff()
Keep the sync run() as a thin run_sync(self.arun(...)) wrapper so there is exactly one execution path.
2. Template variable substitution silently fails whenever YAML contains {...} (JSON/dict/code), and the helper written to fix it is dead code
All adapters render role/goal/backstory/task strings through BaseFrameworkAdapter._format_template, which uses naive str.format():
# framework_adapters/base.py:74
def _format_template(self, template: str, **kwargs) -> str:
try:
return template.format(**kwargs) # line 77
except KeyError as e:
logger.warning("Template formatting failed for key %s; returning original template", e)
return template # <-- returns template UNSUBSTITUTED
except Exception as e:
logger.warning("Template formatting error: %s; returning original template", e)
return template
Any literal brace in the YAML (a JSON example, a dict literal, a Gutenberg block) makes str.format() raise KeyError, the except swallows it, and the original template is returned with {topic} never substituted. Reproduced:
'Write about {topic}' -> 'Write about AI' # ok
'Write about {topic} using <!-- wp:heading {"level":2} -->'
-> KeyError '"level"' -> '... {topic} ... {"level":2} ...' # {topic} NOT substituted
'For {topic}, return {"status": "ok"}'
-> KeyError '"status"' -> 'For {topic}, return {"status": "ok"}' # {topic} NOT substituted
This is used everywhere agents/tasks are built: crewai_adapter.py:70-72,133-134, praisonai_adapter.py:114-116,156-161, autogen_adapter.py:80-93,177-203,394-432. The agent silently receives a literal {topic} instead of the real input.
A correct, brace‑safe implementation already exists but is never wired in — it is dead code:
# agents_generator.py:54 (defined, documented with this exact JSON case, but unused)
def safe_format(template: str, **kwargs) -> str:
...
pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*)\}' # only substitutes known identifiers
return re.sub(pattern, replace_var, template)
grep confirms safe_format has zero call sites (only the def and its docstring example). So we simultaneously have a correctness bug and a DRY violation (two divergent formatters, the correct one orphaned).
Proposed fix. Make _format_template the single source of truth and route it through the brace‑safe logic:
# framework_adapters/base.py
def _format_template(self, template: str, **kwargs) -> str:
if not isinstance(template, str):
return template
import re
def _sub(m):
name = m.group(1)
return str(kwargs[name]) if name in kwargs else m.group(0)
return re.sub(r'\{([a-zA-Z_][a-zA-Z0-9_]*)\}', _sub, template)
Then delete the orphaned agents_generator.safe_format (or have it delegate here) so there is one implementation.
3. The PraisonAI‑native adapter ignores per‑agent llm (and related) fields — multi‑model YAML silently collapses to one model
llm is a documented, schema‑validated per‑agent field (agents_generator.py:397 known_fields; example agents-advanced.yaml:8):
roles:
research_analyst:
llm:
model: "groq/llama3-70b-8192"
writer:
llm: "gpt-4o-mini"
The CrewAI adapter honours it per agent (and even function_calling_llm):
# crewai_adapter.py:79
llm_model = details.get('llm')
if llm_model:
llm = PraisonAIModel(model=llm_model.get("model") or ..., ...).get_model()
But the praisonai‑native adapter — the canonical, agent‑centric path the project promotes — resolves a single global model and applies it to every agent; details.get('llm') is never read:
# praisonai_adapter.py:64
model_name = "gpt-4o-mini"
if llm_config and llm_config[0].get('model'):
model_name = llm_config[0]['model']
...
# praisonai_adapter.py:131 (inside the per-role loop)
agent = PraisonAgent(name=..., role=..., goal=..., backstory=...,
llm=model_name, # same model for EVERY agent
...)
...
manager_llm=config.get('manager_llm') or model_name # :182
(The AutoGen adapter shares the gap — autogen_adapter.py:343,361 also use only llm_config[0].) The result: a perfectly valid multi‑model YAML runs entirely on one model on the praisonai/autogen frameworks, with no warning, while behaving correctly on crewai. That is a silent feature‑parity break on the most‑recommended path.
Proposed fix. Read the per‑agent field in the praisonai adapter and only fall back to the global model:
# praisonai_adapter.py (inside the role loop)
def _resolve_agent_model(details, default_model):
spec = details.get('llm')
if isinstance(spec, str) and spec.strip():
return spec
if isinstance(spec, dict) and spec.get('model'):
return spec['model']
return default_model
agent = PraisonAgent(
name=role_filled, role=role_filled, goal=goal_filled, backstory=backstory_filled,
instructions=details.get('instructions'),
llm=_resolve_agent_model(details, model_name), # honour per-agent llm
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
)
Apply the same resolution in the AutoGen adapter, or—at minimum—emit a warning when a per‑agent llm is present but the active adapter cannot honour it, so the drop is never silent.
Summary
| # |
Gap |
Key evidence |
Pillar violated |
| 1 |
No native async path; arun = thread offload |
base.py:23, praisonai_adapter.py:192, _entrypoint.py:62, SDK astart at agents.py:1206 |
async‑safe by default / 3‑way surface |
| 2 |
Silent template failure on {...}; correct safe_format is dead code |
base.py:74-88, agents_generator.py:54 (0 call sites) |
correctness / DRY |
| 3 |
praisonai adapter ignores per‑agent llm |
praisonai_adapter.py:64-67,131, vs crewai_adapter.py:79 |
feature parity / least surprise |
Scope & method
In‑depth review of the wrapper layer only (
src/praisonai/praisonai), evaluated against the project philosophy (async‑safe by default,3‑way feature surface, DRY, minimal‑but‑correct API). This excludes docs/tests/coverage/file‑size concerns by design. Every finding below was validated by reading the code and (where relevant) reproducing the behaviour. Three concrete, high‑impact gaps are reported with evidence and proposed fixes.1. The wrapper exposes no native async execution path —
arun()just offloads sync work to a threadThe core SDK is async‑capable:
AgentTeam/PraisonAIAgentsexposesastart()(src/praisonai-agents/praisonaiagents/agents/agents.py:1206), plusarun_all_tasks()and async workflows. The wrapper never surfaces any of it.arun():AgentsGenerator.generate_crew_and_kickoff()(agents_generator.py:512) and_run_yaml_workflow()(agents_generator.py:690,workflow.start()at:739) are sync‑only; there is noagenerate_*variant.Why it matters. A caller already inside an event loop (FastAPI, Jupyter, an outer agent) gets a worker thread per run rather than cooperative async. Concurrency is bounded by the thread pool, cancellation/timeouts don't propagate into the SDK coroutines, and the "async‑safe by default" pillar is only superficially met. The
3‑way feature surface(CLI/YAML/Python) is sync everywhere.Proposed fix. Add a real async seam end‑to‑end and let the sync API delegate to it (single source of truth):
Keep the sync
run()as a thinrun_sync(self.arun(...))wrapper so there is exactly one execution path.2. Template variable substitution silently fails whenever YAML contains
{...}(JSON/dict/code), and the helper written to fix it is dead codeAll adapters render
role/goal/backstory/taskstrings throughBaseFrameworkAdapter._format_template, which uses naivestr.format():Any literal brace in the YAML (a JSON example, a dict literal, a Gutenberg block) makes
str.format()raiseKeyError, theexceptswallows it, and the original template is returned with{topic}never substituted. Reproduced:This is used everywhere agents/tasks are built:
crewai_adapter.py:70-72,133-134,praisonai_adapter.py:114-116,156-161,autogen_adapter.py:80-93,177-203,394-432. The agent silently receives a literal{topic}instead of the real input.A correct, brace‑safe implementation already exists but is never wired in — it is dead code:
grepconfirmssafe_formathas zero call sites (only thedefand its docstring example). So we simultaneously have a correctness bug and a DRY violation (two divergent formatters, the correct one orphaned).Proposed fix. Make
_format_templatethe single source of truth and route it through the brace‑safe logic:Then delete the orphaned
agents_generator.safe_format(or have it delegate here) so there is one implementation.3. The PraisonAI‑native adapter ignores per‑agent
llm(and related) fields — multi‑model YAML silently collapses to one modelllmis a documented, schema‑validated per‑agent field (agents_generator.py:397known_fields; exampleagents-advanced.yaml:8):The CrewAI adapter honours it per agent (and even
function_calling_llm):But the praisonai‑native adapter — the canonical, agent‑centric path the project promotes — resolves a single global model and applies it to every agent;
details.get('llm')is never read:(The AutoGen adapter shares the gap —
autogen_adapter.py:343,361also use onlyllm_config[0].) The result: a perfectly valid multi‑model YAML runs entirely on one model on the praisonai/autogen frameworks, with no warning, while behaving correctly on crewai. That is a silent feature‑parity break on the most‑recommended path.Proposed fix. Read the per‑agent field in the praisonai adapter and only fall back to the global model:
Apply the same resolution in the AutoGen adapter, or—at minimum—emit a warning when a per‑agent
llmis present but the active adapter cannot honour it, so the drop is never silent.Summary
arun= thread offloadbase.py:23,praisonai_adapter.py:192,_entrypoint.py:62, SDKastartatagents.py:1206{...}; correctsafe_formatis dead codebase.py:74-88,agents_generator.py:54(0 call sites)llmpraisonai_adapter.py:64-67,131, vscrewai_adapter.py:79