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:
- 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.
- At minimum, a fixed generous default —
asyncio.wait(..., timeout=300) — so the worst case is a five-minute stall instead of an unbounded one.
- 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
Description
1. Problem
The wait tool blocks on the child tasks with no bound of any kind:
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 childasyncio.Taskobjects 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 byget_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 theasyncio.Taskobjects 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 invokesbackground_agents_wait_for_first_completionfor a task whose child had already died, killed by a same-minute resource incident.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:
get_all_tasks(it is inside a tool call; the function-calling loop is suspended with it).A timeout return, by contrast, hands control back to the model with the truth: it can re-check task state (where
_refresh_task_stateWILL 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:
timeoutparameter 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:— 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.asyncio.wait(..., timeout=300)— so the worst case is a five-minute stall instead of an unbounded one._refresh_task_statebetween slices, so a task whose runtime disappeared terminates the wait as LOST even though itsasyncio.Taskreference 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
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