Skip to content

[Bug][P2] async Python plugin tools are never awaited but are recorded as succeeded #141

Description

@Asaf-prog

Problem

A local plugin implemented as async def is accepted during engine build but never executes. Extra records the call as successful even though its body has not run.

Reviewed main at b9acf1e6cc74484120009f626cdb92b1616178c1. Reproduced with Python 3.12, langgraph 1.2.11 and langchain-core 1.6.2, using the real Extra engine and deterministic fake models. No live LLM or external tool was called.

Severity: P2 — silent no-op with a succeeded tool record; the model is handed a stringified coroutine object as the tool result.

Reproduction and observed result

  1. Define plugins/tools/record_value.py with an async def record_value(message: str) -> str that writes to a temporary file and returns its input.
  2. Bind it to an agent with auto_mode=True and request one tool call using a fake model.
  3. Run the real engine.

Observed output:

async tool executed=False
ToolUsageRecord(name='record_value', provider='local', status='succeeded', ...)
RuntimeWarning: coroutine 'record_value' was never awaited

Expected: await the async tool, return its resolved value to the model, and record success only after execution. If async local plugins are intentionally unsupported, reject them clearly at build time rather than silently treating a coroutine object as a successful result.

Cause

GraphBuilder._build_agent_tools always calls:

StructuredTool.from_function(function, description=tool_spec.description)

This registers an async function in the synchronous func slot. LangChain's synchronous execution path returns the coroutine object instead of awaiting it, and the tool-message path serializes it into text. Extra then normalizes that text and records success.

ToolLoader.load only checks that the export is callable, so this reaches runtime without an actionable configuration error.

Suggested fix and acceptance criteria

Detect coroutine functions when binding local tools and register them with coroutine=function; keep func=function for synchronous functions.

  • An async tool's body runs exactly once and the model receives its resolved result.
  • An exception raised inside the async body is recorded as a failure.
  • Cancellation reaches the async tool.
  • Existing synchronous local tools continue to work.
  • No unawaited-coroutine warning or false success appears.

Executable regression

Save as tests/test_review_regression.py and run python -m pytest -q -s tests/test_review_regression.py from the repository root. It reuses existing test helpers. The test currently fails on its behavioral assertion.

import pytest
from langchain_core.messages import AIMessage, ToolMessage

from agent_engine.engine.langgraph.engine import LangGraphEngine
from tests.approvals.test_engine_hitl import _spec as approval_spec


class SingleToolCallModel:
    def __init__(self):
        self.calls = 0

    def bind_tools(self, tools):
        return self

    async def ainvoke(self, messages):
        if any(isinstance(m, ToolMessage) for m in messages):
            return AIMessage(content='done')
        self.calls += 1
        return AIMessage(content='', tool_calls=[{
            'name': 'record_value', 'args': {'message': 'reviewed-value'},
            'id': f'call-{self.calls}', 'type': 'tool_call',
        }])


@pytest.mark.asyncio
async def test_async_local_tool_executes(tmp_path):
    output = tmp_path / 'async-executed.txt'
    tool = tmp_path / 'plugins/tools/record_value.py'
    tool.parent.mkdir(parents=True)
    tool.write_text('from pathlib import Path\n'
                    'async def record_value(message: str) -> str:\n'
                    f'    Path({str(output)!r}).write_text(message)\n'
                    '    return message\n')
    model = SingleToolCallModel()
    async with LangGraphEngine(tmp_path, model_factory=lambda *a, **kw: model) as engine:
        await engine.build(approval_spec('record_value', auto_mode=True))
        result = await engine.run('record a value')
        print(f'async tool executed={output.exists()}, used_tools={result.used_tools}')
        assert output.exists()

From the Extra bug review of 2026-09-05 at b9acf1e. Review environment note: make check passed formatting, lint and mypy; baseline pytest reported 957 passed, 6 failed and 7 errors (API failures from a missing SOCKS dependency, plus three uninvestigated local-MCP example failures), so the baseline suite was not green in that environment.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions