-
Notifications
You must be signed in to change notification settings - Fork 349
Handle background tasks in agent response loop #6685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
1b0206f
a54ad72
b871759
18faf37
fa1a620
3365784
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,21 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| from collections.abc import Callable | ||
| from pathlib import Path | ||
|
|
||
| from claude_agent_sdk import ( | ||
| TERMINAL_TASK_STATUSES, | ||
|
suhaibmujahid marked this conversation as resolved.
|
||
| AssistantMessage, | ||
| ClaudeSDKClient, | ||
| Message, | ||
| ResultMessage, | ||
| SystemMessage, | ||
| TaskNotificationMessage, | ||
| TaskStartedMessage, | ||
| TaskUpdatedMessage, | ||
| TextBlock, | ||
| ThinkingBlock, | ||
| ToolResultBlock, | ||
|
|
@@ -25,6 +33,33 @@ | |
| ) | ||
|
|
||
|
|
||
| class UnsettledResponseError(RuntimeError): | ||
| """``receive_settled_response`` gave up before the agent's turn settled. | ||
|
|
||
| ``pending`` is the ``task_id -> description`` map of deferring tasks | ||
| still open when this was raised (empty if none were ever pending — the | ||
| connection just ended with no result at all). Stored as an attribute | ||
| for callers that want to inspect it programmatically, e.g. to name the | ||
| stuck task(s) rather than just log the message. | ||
| """ | ||
|
|
||
| def __init__(self, reason: str, pending: dict[str, str]): | ||
| self.reason = reason | ||
| self.pending = pending | ||
|
|
||
| def __str__(self) -> str: | ||
| if not self.pending: | ||
| return self.reason | ||
| return f"{self.reason} ({len(self.pending)} task(s) still pending)" | ||
|
|
||
|
|
||
| # Task types whose completion the CLI itself resumes the turn for — mirrors | ||
| # claude_agent_sdk._internal.query.DEFERRING_TASK_TYPES (not public API, so | ||
| # duplicated here rather than imported). | ||
| # https://github.com/anthropics/claude-agent-sdk-python/blob/bc0c9af676d9a63ac20a98cf1b7ba4794382c3cc/src/claude_agent_sdk/_internal/query.py#L38-L52 | ||
| _DEFERRING_TASK_TYPES = frozenset({"local_agent", "local_workflow"}) | ||
|
|
||
|
|
||
| def _truncate(s: str, n: int = 500) -> str: | ||
| return s if len(s) <= n else s[:n] + f"... [{len(s) - n} more chars]" | ||
|
|
||
|
|
@@ -123,3 +158,66 @@ def message(self, msg) -> None: | |
| self._emit(line, always=True) | ||
| if msg.is_error: | ||
| self._emit(f"[done] ERROR: {msg.result}", always=True) | ||
|
|
||
|
|
||
| async def receive_settled_response( | ||
| client: ClaudeSDKClient, | ||
| on_message: Callable[["Message"], None] | None = None, | ||
| *, | ||
| timeout_s: float = 3600, | ||
| ) -> ResultMessage: | ||
| """Drive ``client`` to a *settled* :class:`ResultMessage`. | ||
|
|
||
| ``client.receive_response()`` stops at the first ``ResultMessage``, but | ||
| the CLI can emit one while a task the agent backgrounded is still | ||
| running, reporting the turn "done" prematurely (see | ||
| anthropics/claude-agent-sdk-python#1138). This drains | ||
| ``client.receive_messages()`` instead (it doesn't stop at a | ||
| ``ResultMessage``) and only returns once one arrives with no *deferring* | ||
| task (``local_agent``/``local_workflow``, see ``_DEFERRING_TASK_TYPES``) | ||
| still open — backgrounded shells and Monitor watches run forever by | ||
| design and are never waited on. A task's terminal state can arrive as | ||
| either a ``TaskNotificationMessage`` or a ``TaskUpdatedMessage``, so both | ||
| clear it. | ||
|
|
||
| Args: | ||
| client: A connected client with a query already sent. | ||
| on_message: Called with each message as it streams in, before this | ||
| function's own bookkeeping. Optional. | ||
| timeout_s: Bounds the wait so a task that never settles raises | ||
| ``UnsettledResponseError`` instead of hanging. Defaults to an | ||
| hour (a full Firefox build); pass a larger value for | ||
| longer-running work. | ||
|
|
||
| Raises: | ||
| UnsettledResponseError: timed out, or the connection ended before | ||
| any ``ResultMessage`` arrived. | ||
| """ | ||
| pending: dict[str, str] = {} | ||
| result_msg: ResultMessage | None = None | ||
|
|
||
| try: | ||
| async with asyncio.timeout(timeout_s): | ||
| async for msg in client.receive_messages(): | ||
| if on_message is not None: | ||
| on_message(msg) | ||
|
|
||
| if isinstance(msg, TaskStartedMessage): | ||
| if msg.task_type in _DEFERRING_TASK_TYPES: | ||
| pending[msg.task_id] = msg.description | ||
| elif isinstance(msg, (TaskNotificationMessage, TaskUpdatedMessage)): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be better to limit pending-task tracking only to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch! Fixed in b871759. |
||
| if msg.status in TERMINAL_TASK_STATUSES: | ||
| pending.pop(msg.task_id, None) | ||
| elif isinstance(msg, ResultMessage): | ||
| result_msg = msg | ||
| if not pending: | ||
| return result_msg | ||
| except TimeoutError as exc: | ||
| raise UnsettledResponseError( | ||
| f"timed out after {timeout_s:.0f}s waiting for the response to settle", | ||
| pending, | ||
| ) from exc | ||
|
|
||
| raise UnsettledResponseError( | ||
| "connection ended before a settled ResultMessage arrived", pending | ||
| ) | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3 hours seems too long for the entire agent run.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, but I think about it as a safeguard, that should never happen, but if it happened this is to make it less bad. We should have a sign when this happens and act on it by solving the underlying issues instead of just reducing the general timeout.