From 9281542e06270faf2edd5eebfb8af7456211961e Mon Sep 17 00:00:00 2001 From: Gujiassh Date: Wed, 8 Apr 2026 21:33:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BF=9D=E7=95=99=20?= =?UTF-8?q?end=5Fturn=20=E5=90=8E=E7=9A=84=E5=90=8E=E5=8F=B0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/s13_background_tasks.py | 53 +++++++-- agents/s_full.py | 25 +++- tests/test_background_notifications.py | 156 +++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 tests/test_background_notifications.py diff --git a/agents/s13_background_tasks.py b/agents/s13_background_tasks.py index ea19e6dc2..99dc890a3 100644 --- a/agents/s13_background_tasks.py +++ b/agents/s13_background_tasks.py @@ -91,6 +91,7 @@ def __init__(self): self.tasks = {} # task_id -> {status, result, command, started_at} self._notification_queue = [] # completed task results self._lock = threading.Lock() + self._condition = threading.Condition(self._lock) def _record_path(self, task_id: str) -> Path: return self.dir / f"{task_id}.json" @@ -156,7 +157,7 @@ def _execute(self, task_id: str, command: str): self.tasks[task_id]["finished_at"] = time.time() self.tasks[task_id]["result_preview"] = preview self._persist_task(task_id) - with self._lock: + with self._condition: self._notification_queue.append({ "task_id": task_id, "status": status, @@ -164,6 +165,7 @@ def _execute(self, task_id: str, command: str): "preview": preview, "output_file": str(output_path.relative_to(WORKDIR)), }) + self._condition.notify_all() def check(self, task_id: str = None) -> str: """Check status of one task or list all.""" @@ -189,7 +191,22 @@ def check(self, task_id: str = None) -> str: def drain_notifications(self) -> list: """Return and clear all pending completion notifications.""" - with self._lock: + with self._condition: + notifs = list(self._notification_queue) + self._notification_queue.clear() + return notifs + + def _has_running_tasks_locked(self) -> bool: + return any(task["status"] == "running" for task in self.tasks.values()) + + def has_running_tasks(self) -> bool: + with self._condition: + return self._has_running_tasks_locked() + + def wait_for_notifications(self) -> list: + with self._condition: + while not self._notification_queue and self._has_running_tasks_locked(): + self._condition.wait() notifs = list(self._notification_queue) self._notification_queue.clear() return notifs @@ -286,24 +303,36 @@ def run_edit(path: str, old_text: str, new_text: str) -> str: ] +def inject_background_results(messages: list, notifs: list) -> bool: + if notifs and messages: + notif_text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n.get('preview') or '(no output)'}" + f"{f' (output_file={n['output_file']})' if n.get('output_file') else ''}" + for n in notifs + ) + messages.append( + { + "role": "user", + "content": f"\n{notif_text}\n", + } + ) + return True + return False + + def agent_loop(messages: list): while True: - # Drain background notifications and inject as a synthetic user/assistant - # transcript pair before the next model call (teaching demo behavior). - notifs = BG.drain_notifications() - if notifs and messages: - notif_text = "\n".join( - f"[bg:{n['task_id']}] {n['status']}: {n['preview']} " - f"(output_file={n['output_file']})" - for n in notifs - ) - messages.append({"role": "user", "content": f"\n{notif_text}\n"}) + inject_background_results(messages, BG.drain_notifications()) response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000, ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": + if BG.has_running_tasks() and inject_background_results( + messages, BG.wait_for_notifications() + ): + continue return results = [] for block in response.content: diff --git a/agents/s_full.py b/agents/s_full.py index 42eaddbbf..ada23a39e 100644 --- a/agents/s_full.py +++ b/agents/s_full.py @@ -742,6 +742,21 @@ def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> st ] +def inject_background_results(messages: list, notifs: list) -> bool: + if notifs: + txt = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + messages.append( + { + "role": "user", + "content": f"\n{txt}\n", + } + ) + return True + return False + + # === SECTION: agent_loop === def agent_loop(messages: list): rounds_without_todo = 0 @@ -752,11 +767,7 @@ def agent_loop(messages: list): print("[auto-compact triggered]") messages[:] = auto_compact(messages) # s08: drain background notifications - notifs = BG.drain() - if notifs: - txt = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs) - messages.append({"role": "user", "content": f"\n{txt}\n"}) - messages.append({"role": "assistant", "content": "Noted background results."}) + inject_background_results(messages, BG.drain()) # s10: check lead inbox inbox = BUS.read_inbox("lead") if inbox: @@ -769,6 +780,10 @@ def agent_loop(messages: list): ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": + if BG.has_running_tasks() and inject_background_results( + messages, BG.wait_for_notifications() + ): + continue return # Tool execution results = [] diff --git a/tests/test_background_notifications.py b/tests/test_background_notifications.py new file mode 100644 index 000000000..b321c888c --- /dev/null +++ b/tests/test_background_notifications.py @@ -0,0 +1,156 @@ +import os +import sys +import types +import unittest +from pathlib import Path +from types import SimpleNamespace + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +os.environ.setdefault("MODEL_ID", "test-model") + +fake_anthropic = types.ModuleType("anthropic") + + +class FakeAnthropic: + def __init__(self, *args, **kwargs): + self.messages = SimpleNamespace(create=None) + + +setattr(fake_anthropic, "Anthropic", FakeAnthropic) +sys.modules.setdefault("anthropic", fake_anthropic) + +fake_dotenv = types.ModuleType("dotenv") +setattr(fake_dotenv, "load_dotenv", lambda *args, **kwargs: None) +sys.modules.setdefault("dotenv", fake_dotenv) + +import agents.s13_background_tasks as s13_background_tasks +import agents.s_full as s_full + + +class FakeMessagesAPI: + def __init__(self, responses): + self._responses = iter(responses) + self.call_count = 0 + + def create(self, **kwargs): + self.call_count += 1 + return next(self._responses) + + +class FakeS13BackgroundManager: + def __init__(self): + self._running = True + self.wait_called = False + + def drain_notifications(self): + return [] + + def has_running_tasks(self): + return self._running + + def wait_for_notifications(self): + self.wait_called = True + self._running = False + return [ + { + "task_id": "bg-1", + "status": "completed", + "preview": "done", + "output_file": ".runtime-tasks/bg-1.log", + } + ] + + +class FakeSFullBackgroundManager: + def __init__(self): + self._running = True + self.wait_called = False + + def drain(self): + return [] + + def has_running_tasks(self): + return self._running + + def wait_for_notifications(self): + self.wait_called = True + self._running = False + return [{"task_id": "bg-1", "status": "completed", "result": "done"}] + + +class BackgroundNotificationTests(unittest.TestCase): + def test_s13_agent_loop_waits_for_background_results_after_end_turn(self): + messages = [{"role": "user", "content": "Run tests in the background"}] + fake_bg = FakeS13BackgroundManager() + fake_api = FakeMessagesAPI( + [ + SimpleNamespace( + stop_reason="end_turn", content="Started background work." + ), + SimpleNamespace( + stop_reason="end_turn", content="Background work completed." + ), + ] + ) + original_bg = s13_background_tasks.BG + original_client = s13_background_tasks.client + try: + s13_background_tasks.BG = fake_bg + s13_background_tasks.client = SimpleNamespace(messages=fake_api) + s13_background_tasks.agent_loop(messages) + finally: + s13_background_tasks.BG = original_bg + s13_background_tasks.client = original_client + + self.assertTrue(fake_bg.wait_called) + self.assertEqual(fake_api.call_count, 2) + self.assertTrue( + any( + message["role"] == "user" + and isinstance(message["content"], str) + and "" in message["content"] + for message in messages + ) + ) + + def test_s_full_agent_loop_waits_for_background_results_after_end_turn(self): + messages = [{"role": "user", "content": "Run tests in the background"}] + fake_bg = FakeSFullBackgroundManager() + fake_api = FakeMessagesAPI( + [ + SimpleNamespace( + stop_reason="end_turn", content="Started background work." + ), + SimpleNamespace( + stop_reason="end_turn", content="Background work completed." + ), + ] + ) + original_bg = s_full.BG + original_client = s_full.client + try: + s_full.BG = fake_bg + s_full.client = SimpleNamespace(messages=fake_api) + s_full.agent_loop(messages) + finally: + s_full.BG = original_bg + s_full.client = original_client + + self.assertTrue(fake_bg.wait_called) + self.assertEqual(fake_api.call_count, 2) + self.assertTrue( + any( + message["role"] == "user" + and isinstance(message["content"], str) + and "" in message["content"] + for message in messages + ) + ) + + +if __name__ == "__main__": + unittest.main() From 2cae2cedcd597f632ce9ac87b8f82f9800672387 Mon Sep 17 00:00:00 2001 From: Gujiassh Date: Wed, 8 Apr 2026 21:42:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BF=AE=E6=AD=A3=20?= =?UTF-8?q?s13=20=E5=90=8E=E5=8F=B0=E9=80=9A=E7=9F=A5=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/s13_background_tasks.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/agents/s13_background_tasks.py b/agents/s13_background_tasks.py index 99dc890a3..4fc0483d9 100644 --- a/agents/s13_background_tasks.py +++ b/agents/s13_background_tasks.py @@ -305,11 +305,16 @@ def run_edit(path: str, old_text: str, new_text: str) -> str: def inject_background_results(messages: list, notifs: list) -> bool: if notifs and messages: - notif_text = "\n".join( - f"[bg:{n['task_id']}] {n['status']}: {n.get('preview') or '(no output)'}" - f"{f' (output_file={n['output_file']})' if n.get('output_file') else ''}" - for n in notifs - ) + lines = [] + for notif in notifs: + suffix = "" + if notif.get("output_file"): + suffix = f" (output_file={notif['output_file']})" + lines.append( + f"[bg:{notif['task_id']}] {notif['status']}: " + f"{notif.get('preview') or '(no output)'}{suffix}" + ) + notif_text = "\n".join(lines) messages.append( { "role": "user",