[Bug] syncify and Tool use inconsistent running-event-loop policies
What happened?
DSPy currently has two different policies for synchronously invoking async work when the current thread already has a running event loop:
dspy.utils.syncify.run_async()callsnest_asyncio.apply()and thenrun_until_complete().Tool._run_async_in_sync()closes the created coroutine and raises an actionableValueErrordirecting the caller toawait tool.acall(...). This policy was deliberately established by #10146.
This makes behavior depend on which DSPy abstraction performs the conversion. It also means syncify() applies process-wide event-loop monkey patches as an implicit library side effect. nest_asyncio generally supports standard asyncio.BaseEventLoop loops, but not alternatives such as uvloop.
There is also no direct runtime nest-asyncio dependency in pyproject.toml, despite run_async() importing it on the active-loop path. A user can therefore encounter an environment-dependent import failure only after calling a syncified program inside an active loop.
The project should choose and document one intentional contract:
- Prefer native async calls inside an active loop and make
syncify()fail clearly, consistently withTool; or - Explicitly support nested synchronous execution, own the dependency and global-patching implications, define supported loop implementations, and reconcile why
Toolfollows a different policy.
This issue does not assume that adding nest_asyncio to Tool is the correct resolution. The goal is to remove the inconsistent and implicit behavior.
Steps to reproduce
import asyncio
import dspy
class AsyncProgram(dspy.Module):
async def aforward(self, value: str):
return dspy.Prediction(value=value)
async def main():
program = dspy.syncify(AsyncProgram())
# Active-loop behavior implicitly imports and globally applies nest_asyncio.
result = program(value="hello")
print(result)
asyncio.run(main())
Compare that with an async dspy.Tool called synchronously under the same running loop and allow_tool_async_sync_conversion=True: the tool path raises ValueError and directs the caller to await tool.acall(...) instead of patching the loop.
Suggested regression coverage should include:
- conversion when no event loop is running;
- behavior inside a standard running asyncio loop;
- no leaked un-awaited-coroutine warning on the rejected path;
- explicit behavior for unsupported/custom loop implementations if nested execution remains supported.
DSPy version
Current main (3.3.1 development tree)
Source: stanfordnlp/dspy