#10337·dspy

[Bug] syncify and Tool use inconsistent running-event-loop policies

Author: isaacbmillerCreated Sep 4, 2026Updated Sep 11, 2026
Labelsbug

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() calls nest_asyncio.apply() and then run_until_complete().
  • Tool._run_async_in_sync() closes the created coroutine and raises an actionable ValueError directing the caller to await 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:

  1. Prefer native async calls inside an active loop and make syncify() fail clearly, consistently with Tool; or
  2. Explicitly support nested synchronous execution, own the dependency and global-patching implications, define supported loop implementations, and reconcile why Tool follows 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)