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
- 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.
- Bind it to an agent with
auto_mode=True and request one tool call using a fake model.
- 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.
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.
Problem
A local plugin implemented as
async defis accepted during engine build but never executes. Extra records the call as successful even though its body has not run.Reviewed
mainatb9acf1e6cc74484120009f626cdb92b1616178c1. 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
succeededtool record; the model is handed a stringified coroutine object as the tool result.Reproduction and observed result
plugins/tools/record_value.pywith anasync def record_value(message: str) -> strthat writes to a temporary file and returns its input.auto_mode=Trueand request one tool call using a fake model.Observed output:
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_toolsalways calls:This registers an async function in the synchronous
funcslot. 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.loadonly 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; keepfunc=functionfor synchronous functions.Executable regression
Save as
tests/test_review_regression.pyand runpython -m pytest -q -s tests/test_review_regression.pyfrom the repository root. It reuses existing test helpers. The test currently fails on its behavioral assertion.From the Extra bug review of 2026-09-05 at
b9acf1e. Review environment note:make checkpassed 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.