Skip to content

Commit 86a75cd

Browse files
committed
fix: preserve background notifications after end_turn
1 parent a9c7100 commit 86a75cd

File tree

3 files changed

+951
-202
lines changed

3 files changed

+951
-202
lines changed

agents/s08_background_tasks.py

Lines changed: 147 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(self):
5252
self.tasks = {} # task_id -> {status, result, command}
5353
self._notification_queue = [] # completed task results
5454
self._lock = threading.Lock()
55+
self._condition = threading.Condition(self._lock)
5556

5657
def run(self, command: str) -> str:
5758
"""Start a background thread, return task_id immediately."""
@@ -67,8 +68,12 @@ def _execute(self, task_id: str, command: str):
6768
"""Thread target: run subprocess, capture output, push to queue."""
6869
try:
6970
r = subprocess.run(
70-
command, shell=True, cwd=WORKDIR,
71-
capture_output=True, text=True, timeout=300
71+
command,
72+
shell=True,
73+
cwd=WORKDIR,
74+
capture_output=True,
75+
text=True,
76+
timeout=300,
7277
)
7378
output = (r.stdout + r.stderr).strip()[:50000]
7479
status = "completed"
@@ -80,29 +85,49 @@ def _execute(self, task_id: str, command: str):
8085
status = "error"
8186
self.tasks[task_id]["status"] = status
8287
self.tasks[task_id]["result"] = output or "(no output)"
83-
with self._lock:
84-
self._notification_queue.append({
85-
"task_id": task_id,
86-
"status": status,
87-
"command": command[:80],
88-
"result": (output or "(no output)")[:500],
89-
})
88+
with self._condition:
89+
self._notification_queue.append(
90+
{
91+
"task_id": task_id,
92+
"status": status,
93+
"command": command[:80],
94+
"result": (output or "(no output)")[:500],
95+
}
96+
)
97+
self._condition.notify_all()
9098

9199
def check(self, task_id: str = None) -> str:
92100
"""Check status of one task or list all."""
93101
if task_id:
94102
t = self.tasks.get(task_id)
95103
if not t:
96104
return f"Error: Unknown task {task_id}"
97-
return f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}"
105+
return (
106+
f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}"
107+
)
98108
lines = []
99109
for tid, t in self.tasks.items():
100110
lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}")
101111
return "\n".join(lines) if lines else "No background tasks."
102112

103113
def drain_notifications(self) -> list:
104114
"""Return and clear all pending completion notifications."""
105-
with self._lock:
115+
with self._condition:
116+
notifs = list(self._notification_queue)
117+
self._notification_queue.clear()
118+
return notifs
119+
120+
def _has_running_tasks_locked(self) -> bool:
121+
return any(task["status"] == "running" for task in self.tasks.values())
122+
123+
def has_running_tasks(self) -> bool:
124+
with self._condition:
125+
return self._has_running_tasks_locked()
126+
127+
def wait_for_notifications(self) -> list:
128+
with self._condition:
129+
while not self._notification_queue and self._has_running_tasks_locked():
130+
self._condition.wait()
106131
notifs = list(self._notification_queue)
107132
self._notification_queue.clear()
108133
return notifs
@@ -111,25 +136,48 @@ def drain_notifications(self) -> list:
111136
BG = BackgroundManager()
112137

113138

139+
def inject_background_results(messages: list, notifs: list) -> bool:
140+
if notifs and messages:
141+
notif_text = "\n".join(
142+
f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs
143+
)
144+
messages.append(
145+
{
146+
"role": "user",
147+
"content": f"<background-results>\n{notif_text}\n</background-results>",
148+
}
149+
)
150+
return True
151+
return False
152+
153+
114154
# -- Tool implementations --
115155
def safe_path(p: str) -> Path:
116156
path = (WORKDIR / p).resolve()
117157
if not path.is_relative_to(WORKDIR):
118158
raise ValueError(f"Path escapes workspace: {p}")
119159
return path
120160

161+
121162
def run_bash(command: str) -> str:
122163
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
123164
if any(d in command for d in dangerous):
124165
return "Error: Dangerous command blocked"
125166
try:
126-
r = subprocess.run(command, shell=True, cwd=WORKDIR,
127-
capture_output=True, text=True, timeout=120)
167+
r = subprocess.run(
168+
command,
169+
shell=True,
170+
cwd=WORKDIR,
171+
capture_output=True,
172+
text=True,
173+
timeout=120,
174+
)
128175
out = (r.stdout + r.stderr).strip()
129176
return out[:50000] if out else "(no output)"
130177
except subprocess.TimeoutExpired:
131178
return "Error: Timeout (120s)"
132179

180+
133181
def run_read(path: str, limit: int = None) -> str:
134182
try:
135183
lines = safe_path(path).read_text().splitlines()
@@ -139,6 +187,7 @@ def run_read(path: str, limit: int = None) -> str:
139187
except Exception as e:
140188
return f"Error: {e}"
141189

190+
142191
def run_write(path: str, content: str) -> str:
143192
try:
144193
fp = safe_path(path)
@@ -148,6 +197,7 @@ def run_write(path: str, content: str) -> str:
148197
except Exception as e:
149198
return f"Error: {e}"
150199

200+
151201
def run_edit(path: str, old_text: str, new_text: str) -> str:
152202
try:
153203
fp = safe_path(path)
@@ -161,57 +211,113 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
161211

162212

163213
TOOL_HANDLERS = {
164-
"bash": lambda **kw: run_bash(kw["command"]),
165-
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
166-
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
167-
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
168-
"background_run": lambda **kw: BG.run(kw["command"]),
214+
"bash": lambda **kw: run_bash(kw["command"]),
215+
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
216+
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
217+
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
218+
"background_run": lambda **kw: BG.run(kw["command"]),
169219
"check_background": lambda **kw: BG.check(kw.get("task_id")),
170220
}
171221

172222
TOOLS = [
173-
{"name": "bash", "description": "Run a shell command (blocking).",
174-
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
175-
{"name": "read_file", "description": "Read file contents.",
176-
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
177-
{"name": "write_file", "description": "Write content to file.",
178-
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
179-
{"name": "edit_file", "description": "Replace exact text in file.",
180-
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
181-
{"name": "background_run", "description": "Run command in background thread. Returns task_id immediately.",
182-
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
183-
{"name": "check_background", "description": "Check background task status. Omit task_id to list all.",
184-
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
223+
{
224+
"name": "bash",
225+
"description": "Run a shell command (blocking).",
226+
"input_schema": {
227+
"type": "object",
228+
"properties": {"command": {"type": "string"}},
229+
"required": ["command"],
230+
},
231+
},
232+
{
233+
"name": "read_file",
234+
"description": "Read file contents.",
235+
"input_schema": {
236+
"type": "object",
237+
"properties": {"path": {"type": "string"}, "limit": {"type": "integer"}},
238+
"required": ["path"],
239+
},
240+
},
241+
{
242+
"name": "write_file",
243+
"description": "Write content to file.",
244+
"input_schema": {
245+
"type": "object",
246+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
247+
"required": ["path", "content"],
248+
},
249+
},
250+
{
251+
"name": "edit_file",
252+
"description": "Replace exact text in file.",
253+
"input_schema": {
254+
"type": "object",
255+
"properties": {
256+
"path": {"type": "string"},
257+
"old_text": {"type": "string"},
258+
"new_text": {"type": "string"},
259+
},
260+
"required": ["path", "old_text", "new_text"],
261+
},
262+
},
263+
{
264+
"name": "background_run",
265+
"description": "Run command in background thread. Returns task_id immediately.",
266+
"input_schema": {
267+
"type": "object",
268+
"properties": {"command": {"type": "string"}},
269+
"required": ["command"],
270+
},
271+
},
272+
{
273+
"name": "check_background",
274+
"description": "Check background task status. Omit task_id to list all.",
275+
"input_schema": {
276+
"type": "object",
277+
"properties": {"task_id": {"type": "string"}},
278+
},
279+
},
185280
]
186281

187282

188283
def agent_loop(messages: list):
189284
while True:
190285
# Drain background notifications and inject as system message before LLM call
191-
notifs = BG.drain_notifications()
192-
if notifs and messages:
193-
notif_text = "\n".join(
194-
f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs
195-
)
196-
messages.append({"role": "user", "content": f"<background-results>\n{notif_text}\n</background-results>"})
197-
messages.append({"role": "assistant", "content": "Noted background results."})
286+
inject_background_results(messages, BG.drain_notifications())
198287
response = client.messages.create(
199-
model=MODEL, system=SYSTEM, messages=messages,
200-
tools=TOOLS, max_tokens=8000,
288+
model=MODEL,
289+
system=SYSTEM,
290+
messages=messages,
291+
tools=TOOLS,
292+
max_tokens=8000,
201293
)
202294
messages.append({"role": "assistant", "content": response.content})
203295
if response.stop_reason != "tool_use":
296+
if BG.has_running_tasks() and inject_background_results(
297+
messages, BG.wait_for_notifications()
298+
):
299+
continue
204300
return
205301
results = []
206302
for block in response.content:
207303
if block.type == "tool_use":
208304
handler = TOOL_HANDLERS.get(block.name)
209305
try:
210-
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
306+
output = (
307+
handler(**block.input)
308+
if handler
309+
else f"Unknown tool: {block.name}"
310+
)
211311
except Exception as e:
212312
output = f"Error: {e}"
213313
print(f"> {block.name}: {str(output)[:200]}")
214-
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
314+
results.append(
315+
{
316+
"type": "tool_result",
317+
"tool_use_id": block.id,
318+
"content": str(output),
319+
}
320+
)
215321
messages.append({"role": "user", "content": results})
216322

217323

0 commit comments

Comments
 (0)