Skip to content

Python: [Bug]: background_agents_wait_for_first_completion waits with no bound — a child that never completes suspends the calling agent's run indefinitely #7454

Description

@antsok

Description

1. Problem

The wait tool blocks on the child tasks with no bound of any kind:

# _background_agents.py ~389
done, _ = await asyncio.wait(
    [t for _, t in waitable],
    return_when=asyncio.FIRST_COMPLETED,
)

There is no timeout= argument, no provider-level knob, and no other deadline — the string "timeout" does not appear anywhere in the module. The await resolves only when one of the child asyncio.Task objects completes.

A tool call is the model's turn: while this await is pending, the calling agent's run is suspended inside a tool invocation. If no child task ever completes — a child deadlocked on a provider call that never returns, a lost wakeup, a child whose own awaits are equally unbounded — the tool never returns, the run never produces another event, and nothing upstream of the tool can intervene. On a streaming surface (AG-UI / SSE) the run looks alive server-side while the user sees an indefinite spinner.

Note the asymmetry with the rest of the provider: _refresh_task_state (used by get_all_tasks / get_task_results / continue_task) can observe a RUNNING task whose runtime is gone and mark it LOST — but the wait tool holds direct references to the asyncio.Task objects and awaits them, so it can never observe that transition. The one tool whose entire job is "block until the child is done" is the one tool with no way out when the child will never be done.

2. Observed impact (deployed)

Hit in a deployed application on 2026-07-28 (Azure Container Apps, an agent delegating to a background sub-agent):

  • 21:48:24 — the calling agent invokes background_agents_wait_for_first_completion for a task whose child had already died, killed by a same-minute resource incident.
  • No event of any kind for 14 minutes. The UI shows the run as active throughout.
  • 22:02:28 — the wait is released only as a side effect of the user sending their next message, which started a new run against the same thread.

The hang was the first domino of a worse sequence: the released run resolved the orphaned wait call with a transport placeholder, the calling agent then reported the dead child as actively working ("that final fix is being addressed now"), and it ultimately marked the delegated work complete with the fix unmade, because by then the task was gone from the registry. A bounded wait with an honest "still running / not resolvable" return would have broken the chain at the first link.

3. Why the model cannot recover on its own

The model has no move while the tool is pending — recovery paths all require the tool to return something first:

  • It cannot poll get_all_tasks (it is inside a tool call; the function-calling loop is suspended with it).
  • The harness's per-request roundtrip/function-call limits never fire (they count completed calls, and this one never completes).
  • Host-side run deadlines, where they exist, kill the whole run rather than letting the model handle "the child is stuck" gracefully.

A timeout return, by contrast, hands control back to the model with the truth: it can re-check task state (where _refresh_task_state WILL mark the lost child LOST), report to the user, or re-issue the wait — and each such roundtrip is a point where the harness, the host, and the user can all intervene.

4. Requested end state

In preference order:

  1. A bounded wait with an honest timeout return. A timeout parameter on the tool (model-settable, with a host-configurable default on the provider, e.g. BackgroundAgentsProvider(wait_timeout_seconds=...)). On expiry, return a normal result rather than raising — e.g. `"No task completed within {n}s. Task states:
    • Task 3 [running] ..."— built from_refresh_task_state`, so a LOST child is surfaced at exactly the moment the model is deciding what to do about the wait.
  2. At minimum, a fixed generous defaultasyncio.wait(..., timeout=300) — so the worst case is a five-minute stall instead of an unbounded one.
  3. Optionally, liveness-aware waking: loop the wait in bounded slices and re-run _refresh_task_state between slices, so a task whose runtime disappeared terminates the wait as LOST even though its asyncio.Task reference will never resolve.

Option 1 composes with the LOST machinery the provider already has; today the wait tool is the only consumer that cannot benefit from it.

5. Reproduction sketch

# A background agent whose run never completes:
class HangingAgent:
    name = "hanger"
    async def run(self, *a, **k):
        await asyncio.Event().wait()  # a provider call that never returns

provider = BackgroundAgentsProvider(agents=[HangingAgent()])
# model calls: background_agents_start_task("hanger", "do the thing", "...")
# model calls: background_agents_wait_for_first_completion([1])
#   -> the tool call never returns; the calling agent's run is suspended forever.

Any child whose own awaits lack deadlines (network calls, sub-waits) produces the same shape in production without contrivance.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.12.1

Python Version

No response

Additional Context

No response

Metadata

Metadata

Assignees

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: Python

Type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions