Skip to content

Python: bound background_agents_wait_for_first_completion with a timeout - #7904

Closed
Manoj Meruva (manojmeruva) wants to merge 2 commits into
microsoft:mainfrom
manojmeruva:fix-7454-background-agents-wait-timeout
Closed

Python: bound background_agents_wait_for_first_completion with a timeout#7904
Manoj Meruva (manojmeruva) wants to merge 2 commits into
microsoft:mainfrom
manojmeruva:fix-7454-background-agents-wait-timeout

Conversation

@manojmeruva

Copy link
Copy Markdown

background_agents_wait_for_first_completion called asyncio.wait() with no timeout, so a child agent that never completed suspended the parent's function-calling loop indefinitely. Because the tool holds direct asyncio.Task references, _refresh_task_state never ran while the wait was parked, so a task whose runtime reference had disappeared was never promoted to LOST, and the model could not poll task status to recover.

Add a provider-level wait_timeout_seconds default of 300 seconds and an optional per-call timeout_seconds override; either may be None to preserve the previous unbounded behavior. The wait now runs in bounded slices, refreshing task state between them so a LOST task ends the wait early rather than stalling for the full timeout. On timeout the tool returns current task statuses to the model instead of raising.

Fixes #7454

Motivation & Context

background_agents_wait_for_first_completion called asyncio.wait() with no timeout, so a background agent that never completes suspends the calling agent's run indefinitely.

The hang cannot be recovered from inside the run: the wait happens inside a tool invocation, which suspends the function-calling loop, so the model cannot check task status or take any other action while it is parked.

Description & Review Guide

  • What are the major changes?

    • BackgroundAgentsProvider accepts wait_timeout_seconds, defaulting to 300 seconds.
    • background_agents_wait_for_first_completion accepts an optional timeout_seconds that overrides the default for a single call.
    • Setting either to None preserves the previous unbounded behavior.
    • On timeout, the tool refreshes task state and returns the current status of each requested task to the model instead of raising.
    • create_harness_agent gained background_agents_wait_timeout_seconds so harness users can set it.
  • What is the impact of these changes?

    Not a breaking change — the new parameters are keyword-only with defaults, and the completion path is unchanged. The intended behavioral change is that a wait which would previously block forever now returns after 300 seconds by default; wait_timeout_seconds=None restores the old behavior.

  • What do you want reviewers to focus on?

    Whether 300 seconds is the right default, and the timeout validation split: a bad provider-level value raises ValueError from the constructor, while a bad model-supplied timeout_seconds is returned as an error string so a bad argument does not fail the tool invocation.

Related Issue

Fixes #7454

#7464 proposed the same fix for this issue but was closed without merging. This PR implements it against current main.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

background_agents_wait_for_first_completion called asyncio.wait() with no
timeout, so a child agent that never completed suspended the parent's
function-calling loop indefinitely. Because the tool holds direct asyncio.Task
references, _refresh_task_state never ran while the wait was parked, so a task
whose runtime reference had disappeared was never promoted to LOST, and the
model could not poll task status to recover.

Add a provider-level wait_timeout_seconds default of 300 seconds and an
optional per-call timeout_seconds override; either may be None to preserve the
previous unbounded behavior. The wait now runs in bounded slices, refreshing
task state between them so a LOST task ends the wait early rather than stalling
for the full timeout. On timeout the tool returns current task statuses to the
model instead of raising.

Fixes microsoft#7454

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bounded background-agent waits to prevent stalled parent runs.

Changes:

  • Adds configurable provider and per-call timeouts.
  • Refreshes task state during sliced waits.
  • Exposes timeout configuration through the harness API and adds tests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
_background_agents.py Implements timeout validation, sliced waits, and status reporting.
_agent.py Forwards harness timeout configuration.
test_harness_background_agents.py Tests timeout, completion, validation, and lost-task behavior.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +341 to +343
tasks = _refresh_task_state(session, state, runtime, source_id=source_id)
if not any(t.id in waited_ids and t.status == BackgroundTaskStatus.RUNNING for t in tasks):
return set()
Comment on lines +185 to +189
if timeout_seconds != timeout_seconds: # NaN never compares greater than 0.
raise ValueError("Background agent wait timeout must not be NaN.")
if timeout_seconds <= 0:
raise ValueError(f"Background agent wait timeout must be greater than 0; got {timeout_seconds!r}.")
return float(timeout_seconds)
skills_paths: str | Path | Sequence[str | Path] | None = None,
background_agents: Sequence[SupportsAgentRun] | None = None,
background_agents_instructions: str | None = None,
background_agents_wait_timeout_seconds: float | None = DEFAULT_BACKGROUND_AGENTS_WAIT_TIMEOUT_SECONDS,
Comment on lines +654 to +661
if not done:
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
statuses = ", ".join(f"task {t.id}: {t.status.value}" for t in tasks if t.id in task_ids)
return (
f"Timed out after {timeout} seconds waiting for tasks {task_ids} to complete. "
f"Current status: {statuses or 'unknown'}. "
"The tasks may still be running; wait again or check their status."
)
Comment on lines 596 to +600
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
async def background_agents_wait_for_first_completion(
task_ids: list[int], timeout_seconds: float | None = None
) -> str:
"""Block until the first of the specified background tasks completes, or the timeout elapses.
- Reject non-finite timeouts. Positive infinity previously passed validation
  and produced an infinite deadline, restoring the unbounded wait this change
  exists to prevent. Convert to float first so a very large int raises the
  documented ValueError instead of OverflowError.
- End the wait as soon as any requested task reaches a terminal state. When
  waiting on several IDs, one task becoming LOST was previously masked by
  another still running, parking the caller until the full timeout.
- Distinguish a terminal-state wakeup from deadline expiry. An early return no
  longer reports "Timed out after N seconds" (or "after None seconds" in
  unbounded mode) when the deadline had not elapsed.
- Bind the provider default as the tool parameter's default so an explicit
  timeout_seconds=None waits without a bound instead of being indistinguishable
  from omission.
- Declare background_agents_wait_timeout_seconds in _agent.pyi so type checkers
  and editors accept the new keyword.
@manojmeruva

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@westey-m

Copy link
Copy Markdown
Contributor

Thanks for the contribution Manoj Meruva (@manojmeruva). I was actually working on a fix for this already, and it is scoped a little bit more narrowly on purpose than what you have here. Therefore closing this in favor of #7908.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

3 participants