Skip to content

Commit e34e3cb

Browse files
Revert "Handle background tasks in agent response loop" (#6820)
1 parent ded4174 commit e34e3cb

5 files changed

Lines changed: 21 additions & 272 deletions

File tree

agents/bug-fix/hackbot_agents/bug_fix/agent.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,12 @@
1919
ClaudeAgentOptions,
2020
ClaudeSDKClient,
2121
McpServerConfig,
22+
ResultMessage,
2223
)
2324
from hackbot_runtime import ActionsRecorder, AgentError, HackbotAgentResult
2425
from hackbot_runtime.actions import ACTIONS_SERVER_NAME
2526
from hackbot_runtime.actions.claude_sdk import actions_server_for, actions_to_tool_names
26-
from hackbot_runtime.claude import (
27-
Reporter,
28-
UnsettledResponseError,
29-
receive_settled_response,
30-
)
27+
from hackbot_runtime.claude import Reporter
3128

3229
from .config import (
3330
BUGZILLA_NEEDINFO_ACTIONS,
@@ -142,7 +139,6 @@ async def run_bug_fix(
142139
verbose: bool = False,
143140
log: Path | None = None,
144141
actions_recorder: ActionsRecorder | None = None,
145-
background_task_timeout_s: float = 3 * 60 * 60,
146142
) -> BugFixResult:
147143
"""Triage and fix a single Bugzilla bug with a claude-agent-sdk agent.
148144
@@ -207,19 +203,18 @@ async def run_bug_fix(
207203
setting_sources=[],
208204
)
209205

206+
result_msg: ResultMessage | None = None
210207
with Reporter(verbose=verbose, log_path=log) as reporter:
211208
reporter.header(f"bug {bug}")
212209
async with ClaudeSDKClient(options=options) as client:
213210
await client.query(user_prompt)
214-
try:
215-
result_msg = await receive_settled_response(
216-
client,
217-
on_message=reporter.message,
218-
timeout_s=background_task_timeout_s,
219-
)
220-
except UnsettledResponseError as exc:
221-
raise AgentError(f"bug {bug}: agent run did not settle: {exc}") from exc
211+
async for msg in client.receive_response():
212+
reporter.message(msg)
213+
if isinstance(msg, ResultMessage):
214+
result_msg = msg
222215

216+
if result_msg is None:
217+
raise AgentError(f"bug {bug}: agent produced no result message")
223218
if result_msg.is_error:
224219
raise AgentError(
225220
f"bug {bug} triage failed: {result_msg.result or result_msg.subtype}"

libs/agent-tools/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ bugzilla = [
1515
"six",
1616
]
1717
firefox = ["grizzly-framework", "prefpicker"]
18-
claude-sdk = ["claude-agent-sdk>=0.2.30"]
18+
claude-sdk = ["claude-agent-sdk>=0.1.30"]
1919
searchfox = ["searchfox>=0.20.3"]
2020
vcs = ["httpx"]
2121

libs/hackbot-runtime/hackbot_runtime/claude.py

Lines changed: 0 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,13 @@
1010

1111
from __future__ import annotations
1212

13-
import asyncio
1413
import json
15-
from collections.abc import Callable
1614
from pathlib import Path
1715

1816
from claude_agent_sdk import (
19-
TERMINAL_TASK_STATUSES,
2017
AssistantMessage,
21-
ClaudeSDKClient,
22-
Message,
2318
ResultMessage,
2419
SystemMessage,
25-
TaskNotificationMessage,
26-
TaskStartedMessage,
27-
TaskUpdatedMessage,
2820
TextBlock,
2921
ThinkingBlock,
3022
ToolResultBlock,
@@ -33,33 +25,6 @@
3325
)
3426

3527

36-
class UnsettledResponseError(RuntimeError):
37-
"""``receive_settled_response`` gave up before the agent's turn settled.
38-
39-
``pending`` is the ``task_id -> description`` map of deferring tasks
40-
still open when this was raised (empty if none were ever pending — the
41-
connection just ended with no result at all). Stored as an attribute
42-
for callers that want to inspect it programmatically, e.g. to name the
43-
stuck task(s) rather than just log the message.
44-
"""
45-
46-
def __init__(self, reason: str, pending: dict[str, str]):
47-
self.reason = reason
48-
self.pending = pending
49-
50-
def __str__(self) -> str:
51-
if not self.pending:
52-
return self.reason
53-
return f"{self.reason} ({len(self.pending)} task(s) still pending)"
54-
55-
56-
# Task types whose completion the CLI itself resumes the turn for — mirrors
57-
# claude_agent_sdk._internal.query.DEFERRING_TASK_TYPES (not public API, so
58-
# duplicated here rather than imported).
59-
# https://github.com/anthropics/claude-agent-sdk-python/blob/bc0c9af676d9a63ac20a98cf1b7ba4794382c3cc/src/claude_agent_sdk/_internal/query.py#L38-L52
60-
_DEFERRING_TASK_TYPES = frozenset({"local_agent", "local_workflow"})
61-
62-
6328
def _truncate(s: str, n: int = 500) -> str:
6429
return s if len(s) <= n else s[:n] + f"... [{len(s) - n} more chars]"
6530

@@ -158,66 +123,3 @@ def message(self, msg) -> None:
158123
self._emit(line, always=True)
159124
if msg.is_error:
160125
self._emit(f"[done] ERROR: {msg.result}", always=True)
161-
162-
163-
async def receive_settled_response(
164-
client: ClaudeSDKClient,
165-
on_message: Callable[["Message"], None] | None = None,
166-
*,
167-
timeout_s: float = 3600,
168-
) -> ResultMessage:
169-
"""Drive ``client`` to a *settled* :class:`ResultMessage`.
170-
171-
``client.receive_response()`` stops at the first ``ResultMessage``, but
172-
the CLI can emit one while a task the agent backgrounded is still
173-
running, reporting the turn "done" prematurely (see
174-
anthropics/claude-agent-sdk-python#1138). This drains
175-
``client.receive_messages()`` instead (it doesn't stop at a
176-
``ResultMessage``) and only returns once one arrives with no *deferring*
177-
task (``local_agent``/``local_workflow``, see ``_DEFERRING_TASK_TYPES``)
178-
still open — backgrounded shells and Monitor watches run forever by
179-
design and are never waited on. A task's terminal state can arrive as
180-
either a ``TaskNotificationMessage`` or a ``TaskUpdatedMessage``, so both
181-
clear it.
182-
183-
Args:
184-
client: A connected client with a query already sent.
185-
on_message: Called with each message as it streams in, before this
186-
function's own bookkeeping. Optional.
187-
timeout_s: Bounds the wait so a task that never settles raises
188-
``UnsettledResponseError`` instead of hanging. Defaults to an
189-
hour (a full Firefox build); pass a larger value for
190-
longer-running work.
191-
192-
Raises:
193-
UnsettledResponseError: timed out, or the connection ended before
194-
any ``ResultMessage`` arrived.
195-
"""
196-
pending: dict[str, str] = {}
197-
result_msg: ResultMessage | None = None
198-
199-
try:
200-
async with asyncio.timeout(timeout_s):
201-
async for msg in client.receive_messages():
202-
if on_message is not None:
203-
on_message(msg)
204-
205-
if isinstance(msg, TaskStartedMessage):
206-
if msg.task_type in _DEFERRING_TASK_TYPES:
207-
pending[msg.task_id] = msg.description
208-
elif isinstance(msg, (TaskNotificationMessage, TaskUpdatedMessage)):
209-
if msg.status in TERMINAL_TASK_STATUSES:
210-
pending.pop(msg.task_id, None)
211-
elif isinstance(msg, ResultMessage):
212-
result_msg = msg
213-
if not pending:
214-
return result_msg
215-
except TimeoutError as exc:
216-
raise UnsettledResponseError(
217-
f"timed out after {timeout_s:.0f}s waiting for the response to settle",
218-
pending,
219-
) from exc
220-
221-
raise UnsettledResponseError(
222-
"connection ended before a settled ResultMessage arrived", pending
223-
)
Lines changed: 2 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,6 @@
1-
"""Tests for the shared claude-agent-sdk helpers (hackbot_runtime.claude)."""
1+
"""Tests for the shared claude-agent-sdk Reporter (hackbot_runtime.claude)."""
22

3-
import asyncio
4-
5-
import pytest
6-
from claude_agent_sdk import (
7-
ResultMessage,
8-
SystemMessage,
9-
TaskNotificationMessage,
10-
TaskStartedMessage,
11-
TaskUpdatedMessage,
12-
)
13-
from hackbot_runtime.claude import (
14-
Reporter,
15-
UnsettledResponseError,
16-
_truncate,
17-
receive_settled_response,
18-
)
3+
from hackbot_runtime.claude import Reporter, _truncate
194

205

216
def test_truncate_short_string_unchanged():
@@ -49,136 +34,3 @@ def test_no_log_file_when_path_is_none(tmp_path):
4934
with Reporter(verbose=True, log_path=None) as reporter:
5035
reporter.header("section")
5136
assert not list(tmp_path.iterdir())
52-
53-
54-
class _FakeClient:
55-
"""Replays a fixed message list from ``receive_messages()``.
56-
57-
If ``hang_after`` is set, blocks forever once the list is exhausted,
58-
simulating a connection left open with no further messages — the
59-
situation ``timeout_s`` in ``receive_settled_response`` is meant to
60-
bound.
61-
"""
62-
63-
def __init__(self, messages, hang_after: bool = False):
64-
self._messages = messages
65-
self._hang_after = hang_after
66-
67-
async def receive_messages(self):
68-
for msg in self._messages:
69-
yield msg
70-
if self._hang_after:
71-
await asyncio.Event().wait()
72-
73-
74-
def _result(is_error: bool = False, num_turns: int = 1) -> ResultMessage:
75-
return ResultMessage(
76-
subtype="success",
77-
duration_ms=1,
78-
duration_api_ms=1,
79-
is_error=is_error,
80-
num_turns=num_turns,
81-
session_id="s1",
82-
)
83-
84-
85-
def _task_started(task_id: str, task_type: str = "local_agent") -> TaskStartedMessage:
86-
return TaskStartedMessage(
87-
subtype="task_started",
88-
data={},
89-
task_id=task_id,
90-
description="do a thing",
91-
uuid="u1",
92-
session_id="s1",
93-
task_type=task_type,
94-
)
95-
96-
97-
def _task_notification(
98-
task_id: str, status: str = "completed"
99-
) -> TaskNotificationMessage:
100-
return TaskNotificationMessage(
101-
subtype="task_notification",
102-
data={},
103-
task_id=task_id,
104-
status=status,
105-
output_file="",
106-
summary="done",
107-
uuid="u2",
108-
session_id="s1",
109-
)
110-
111-
112-
def _task_updated(task_id: str, status: str = "completed") -> TaskUpdatedMessage:
113-
return TaskUpdatedMessage(
114-
subtype="task_updated",
115-
data={},
116-
task_id=task_id,
117-
patch={"status": status},
118-
status=status,
119-
)
120-
121-
122-
async def test_receive_settled_response_returns_immediately_when_nothing_pending():
123-
result = _result()
124-
client = _FakeClient([result])
125-
seen = []
126-
127-
got = await receive_settled_response(client, on_message=seen.append)
128-
129-
assert got is result
130-
assert seen == [result]
131-
132-
133-
async def test_receive_settled_response_keeps_draining_past_early_result():
134-
started = _task_started("t1")
135-
early_result = _result(num_turns=1)
136-
notification = _task_notification("t1")
137-
final_result = _result(num_turns=2)
138-
client = _FakeClient([started, early_result, notification, final_result])
139-
140-
got = await receive_settled_response(client)
141-
142-
# The first ResultMessage arrived while "t1" was still open — it must be
143-
# ignored in favor of the one that follows the task's terminal message.
144-
assert got is final_result
145-
146-
147-
async def test_receive_settled_response_task_updated_also_clears_pending():
148-
started = _task_started("t1")
149-
early_result = _result(num_turns=1)
150-
updated = _task_updated("t1")
151-
final_result = _result(num_turns=2)
152-
client = _FakeClient([started, early_result, updated, final_result])
153-
154-
got = await receive_settled_response(client)
155-
156-
assert got is final_result
157-
158-
159-
async def test_receive_settled_response_ignores_non_deferring_task_types():
160-
# A backgrounded shell (task_type="local_bash") can run indefinitely by
161-
# design — the CLI itself never holds the result frame back for one, so
162-
# neither should we. Settling immediately (rather than waiting on "t1")
163-
# is the correct behavior here, not a race we need to rescue.
164-
started = _task_started("t1", task_type="local_bash")
165-
result = _result()
166-
client = _FakeClient([started, result])
167-
168-
got = await receive_settled_response(client)
169-
170-
assert got is result
171-
172-
173-
async def test_receive_settled_response_raises_on_timeout_when_task_never_settles():
174-
client = _FakeClient([_task_started("t1"), _result()], hang_after=True)
175-
176-
with pytest.raises(UnsettledResponseError):
177-
await receive_settled_response(client, timeout_s=0.05)
178-
179-
180-
async def test_receive_settled_response_raises_when_stream_ends_without_result():
181-
client = _FakeClient([SystemMessage(subtype="init", data={})])
182-
183-
with pytest.raises(UnsettledResponseError):
184-
await receive_settled_response(client)

uv.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)