Skip to content

Wrapper layer: sync/async path drift, silent DB writes, and adapter inconsistency in praisonai/Β #1883

@MervinPraison

Description

@MervinPraison

Scope

In-depth analysis of src/praisonai/praisonai/ against the project's stated philosophy (Protocol-driven Β· DRY Β· Multi-agent + async safe Β· Performance-first Β· Production-ready). The findings below are not stylistic β€” each one has either been observed to cause behavioural drift between code paths, silently lose data, or break a feature the YAML schema advertises.

This report focuses on the top 3 highest-impact gaps with code citations and concrete fixes.


1. agents_generator.py β€” sync and async paths are copy-pasted, and have already drifted into correctness bugs

AgentsGenerator.generate_crew_and_kickoff() (sync, lines 472–626) and AgentsGenerator._arun_framework() (async, lines 660–795) implement the same end-to-end pipeline: load YAML, apply CLI overrides, normalise agents β†’ roles, validate config, collect tools from YAML, resolve them, instantiate them, pick a framework adapter, set up observability, dispatch to run/arun. They duplicate roughly 150 lines with the only real change being run(...) β†’ await arun(...). The same problem exists between _run_yaml_workflow (851) and _arun_yaml_workflow (797).

This isn't theoretical β€” the two branches have already diverged in ways that change behaviour:

1a. AutoGen version selection only exists on the async path

agents_generator.py:744-763 (async path) reads autogen_version from YAML/env and chooses between autogen and autogen_v4. The sync path has no equivalent β€” it just uses whatever adapter was resolved earlier (line 599-600). A user who configures:

framework: autogen
autogen_version: v0.4

and runs synchronously gets autogen v0.2 silently. Running the same file via the async API picks v0.4. Same YAML, different framework.

1b. _validate_cli_backend_compatibility only runs on the async path

# agents_generator.py:783-784  (async path)
self._validate_cli_backend_compatibility(config, framework)

There is no matching call in generate_crew_and_kickoff(). A YAML that combines cli_backend: with framework: crewai is rejected with a clear ValueError from arun(...), but silently accepted by the sync path and then fails deep inside the CrewAI adapter with a confusing error.

1c. Tool resolution + instantiation differ between paths

# Sync β€” agents_generator.py:558-566
for tool_name in needed_tools:
    try:
        resolved_tool = self.tool_resolver.resolve(tool_name, instantiate=True)
        if resolved_tool is not None:
            tools_dict[tool_name] = resolved_tool
    except Exception as e:
        self.logger.warning(f"Failed to resolve or instantiate tool '{tool_name}': {e}")
        continue
# Async β€” agents_generator.py:702-713
for tool_name in needed_tools:
    try:
        resolved_tool = self.tool_resolver.resolve(tool_name)
        if resolved_tool is None:
            self.logger.warning(f"Tool '{tool_name}' not found")
            continue
        tools_dict[tool_name] = (
            resolved_tool() if inspect.isclass(resolved_tool) else resolved_tool
        )
    except Exception as e:
        self.logger.warning(f"Failed to initialize tool '{tool_name}': {e}")
        continue

The semantics are almost the same, but: (a) the sync path goes through ToolResolver.resolve(..., instantiate=True) which returns the instantiated cached value, while the async path instantiates a fresh instance on every call (because it skips the instantiate flag and does it itself); (b) only the async path warns on unresolved tools; (c) the warning message is different ("Failed to resolve or instantiate" vs "Failed to initialize"), which breaks log-based alerting.

Suggested fix

Extract the shared pipeline into a helper that returns a prepared (adapter, config, llm_config, topic, tools_dict, ...) tuple, and have both entry points dispatch to adapter.run / adapter.arun from a single source of truth. Sketch:

# agents_generator.py

def _prepare_for_run(self, config: dict) -> "_PreparedRun":
    """Single source of truth for YAML normalisation, validation,
    CLI-backend compatibility, tool resolution, AutoGen version
    selection, and adapter resolution. Used by BOTH sync and async."""
    self._normalise_canonical_format(config)
    self._validate_agents_config(config)
    self._validate_cli_backend_compatibility(config, self.framework)  # always run

    topic = config.get('input', config.get('topic', ''))
    tools_dict = self._build_tools_dict(config)             # shared loop

    framework = self._select_autogen_version(               # shared logic
        self.framework or config.get('framework', 'crewai'),
        config,
    )
    adapter = self._get_framework_adapter(framework).resolve()
    assert_framework_available(adapter.name)
    adapter.setup(framework_tag=adapter.name)
    return _PreparedRun(adapter, config, topic, tools_dict)


def generate_crew_and_kickoff(self):
    config = self._load_config()
    if self._is_workflow(config):
        return self._run_yaml_workflow(config)
    if self.cli_config:
        self._merge_cli_config(config, self.cli_config)
    prep = self._prepare_for_run(config)
    return prep.adapter.run(
        prep.config, self.config_list, prep.topic,
        tools_dict=prep.tools_dict,
        agent_callback=self.agent_callback,
        task_callback=self.task_callback,
        cli_config=self.cli_config,
    )


async def agenerate_crew_and_kickoff(self):
    config = self._load_config()
    if self._is_workflow(config):
        return await self._arun_yaml_workflow(config)
    if self.cli_config:
        self._merge_cli_config(config, self.cli_config)
    prep = self._prepare_for_run(config)
    return await prep.adapter.arun(
        prep.config, self.config_list, prep.topic,
        tools_dict=prep.tools_dict,
        agent_callback=self.agent_callback,
        task_callback=self.task_callback,
        cli_config=self.cli_config,
    )

This eliminates the drift in a single change and aligns with the project's DRY pillar.


2. db/adapter.py β€” write hooks check self._conversation_store before _init_stores(), so any write that arrives before on_agent_start is silently dropped

PraisonAIDB.__init__ sets self._conversation_store = None and defers backend construction to _init_stores(), which is called lazily. Read-side hooks (on_agent_start, get_runs, export_session, import_session, ...) all call _init_stores() first β€” verified by grep _init_stores db/adapter.py, which finds it on lines 153, 337, 369, 404, 527, 569, 594, 617, 642, 664, 697, 706.

The write-side hooks do not:

# db/adapter.py:191-212
def on_user_message(self, session_id, content, metadata=None) -> None:
    """Called when user sends a message."""
    if not self._conversation_store:   # <-- still None until on_agent_start runs
        return
    ...
    self._conversation_store.add_message(session_id, msg)

The same pattern is in on_agent_message (214), on_tool_call (237), on_agent_end (270), on_run_start (285), and on_run_end (309).

If any of those hooks fires before on_agent_start β€” which happens with REST endpoints that POST a message into a session that wasn't opened via on_agent_start, with imported sessions, or with hosted backends that route messages through the adapter directly β€” the data is silently lost. There is no exception, no warning, no telemetry.

Verification

from praisonai.db import PraisonAIDB
db = PraisonAIDB(database_url="sqlite:///./test.db")
db.on_user_message("s1", "hello")   # silently dropped: stores never initialised
db.on_agent_start("agent", "s1")    # NOW stores initialise
db.on_user_message("s1", "world")   # this one is persisted

Suggested fix

Add self._init_stores() to the top of every write hook, mirroring the read hooks:

def on_user_message(self, session_id, content, metadata=None) -> None:
    self._init_stores()
    if not self._conversation_store:
        return
    ...

A cleaner long-term fix is a decorator so this can't be forgotten on the next hook added:

def _ensures_init(fn):
    @functools.wraps(fn)
    def _wrapper(self, *args, **kwargs):
        self._init_stores()
        return fn(self, *args, **kwargs)
    return _wrapper

class PraisonAIDB:
    @_ensures_init
    def on_user_message(self, session_id, content, metadata=None) -> None:
        if not self._conversation_store:
            return
        ...

3. framework_adapters/crewai_adapter.py β€” string-form llm: YAML crashes CrewAI but works on PraisonAI, and the same 4-line block is duplicated 4 times

The YAML schema accepts two forms for the per-agent llm: field. The PraisonAI adapter handles both:

# framework_adapters/praisonai_adapter.py:26-39
def _resolve_agent_model(self, details, default_model):
    llm_spec = details.get('llm')
    if isinstance(llm_spec, str) and llm_spec.strip():
        return llm_spec.strip()                        # "gpt-4o-mini"
    if isinstance(llm_spec, dict) and llm_spec.get('model'):
        return llm_spec['model']                       # {"model": "..."}
    return default_model

The CrewAI adapter doesn't β€” it assumes dict:

# framework_adapters/crewai_adapter.py:79-90
llm_model = details.get('llm')
if llm_model:
    llm = PraisonAIModel(
        model=llm_model.get("model")                   # <-- AttributeError on str
                or os.environ.get("MODEL_NAME")
                or "openai/gpt-4o-mini",
        base_url=llm_config[0].get('base_url') if llm_config else None,
        api_key=llm_config[0].get('api_key') if llm_config else None,
    ).get_model()
else:
    llm = PraisonAIModel(
        base_url=llm_config[0].get('base_url') if llm_config else None,
        api_key=llm_config[0].get('api_key') if llm_config else None,
    ).get_model()

A YAML like

framework: crewai
roles:
  researcher:
    llm: "gpt-4o-mini"
    role: Researcher
    goal: Find things
    backstory: ...

works under framework: praisonai and crashes under framework: crewai with AttributeError: 'str' object has no attribute 'get'. This violates the project's "3-way feature surface" β€” the YAML knob behaves differently depending on which adapter executes it.

The same PraisonAIModel(...).get_model() block is duplicated 4 times in crewai_adapter.py:81-104 (for llm and function_calling_llm, each with a "has spec / no spec" branch). The hard-coded "openai/gpt-4o-mini" and the base_url/api_key plumbing are also repeated in praisonai_adapter.py and autogen_adapter.py.

Suggested fix

Promote _resolve_agent_model (or a stronger variant returning a fully-built PraisonAIModel) onto BaseFrameworkAdapter so every adapter uses one resolver, and collapse the 4 branches in crewai_adapter.py:

# framework_adapters/base.py
class BaseFrameworkAdapter:
    DEFAULT_MODEL = "openai/gpt-4o-mini"

    def _resolve_llm(self, spec, llm_config, *, field="llm"):
        """Build a PraisonAIModel from a per-agent llm/function_calling_llm spec.
        Accepts str, dict, or None. Single source of truth for all adapters."""
        from ..inc import PraisonAIModel
        base = llm_config[0].get('base_url') if llm_config else None
        key = llm_config[0].get('api_key') if llm_config else None

        if isinstance(spec, str) and spec.strip():
            model = spec.strip()
        elif isinstance(spec, dict) and spec.get('model'):
            model = spec['model']
        else:
            model = os.environ.get("MODEL_NAME") or self.DEFAULT_MODEL

        return PraisonAIModel(model=model, base_url=base, api_key=key).get_model()
# framework_adapters/crewai_adapter.py
llm = self._resolve_llm(details.get('llm'), llm_config)
function_calling_llm = self._resolve_llm(details.get('function_calling_llm'), llm_config)

This fixes the string-form crash, removes the 4x duplication, makes MODEL_NAME / DEFAULT_MODEL resolution uniform across adapters, and gives PraisonAI/AutoGen adapters the same helper.


Validation

  • All file paths and line numbers are against current main (commit at session start).
  • The sync/async drift (Github actions fixΒ #1) was confirmed by reading both functions end-to-end and diffing them mentally β€” the only line that has to differ is run(...) vs await arun(...).
  • The DB silent-drop (Merge pull request #1 from MervinPraison/developΒ #2) was confirmed by grep _init_stores db/adapter.py showing the call appears on every read-side hook but is missing from on_user_message / on_agent_message / on_tool_call / on_agent_end / on_run_start / on_run_end.
  • The adapter inconsistency (MainΒ #3) was confirmed by reading both _resolve_agent_model in praisonai_adapter.py:26 and the bare .get("model") call in crewai_adapter.py:82.

Out of scope for this issue (per request): docs, tests, coverage, file-size, line-count concerns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysisdocumentationImprovements or additions to documentationperformance

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions