From a2e5be85cdabfb941e76020edf2197d287a4aace Mon Sep 17 00:00:00 2001 From: Haoran Date: Mon, 17 Aug 2026 14:25:07 +0800 Subject: [PATCH] fix: merge async notices in full agent --- agents/s08_background_tasks.py | 51 +++++++++------ agents/s_full.py | 50 ++++++++++++--- tests/test_s_full_background.py | 106 ++++++++++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 33 deletions(-) diff --git a/agents/s08_background_tasks.py b/agents/s08_background_tasks.py index 410732b1..1fa87104 100644 --- a/agents/s08_background_tasks.py +++ b/agents/s08_background_tasks.py @@ -185,31 +185,42 @@ TOOLS = [ ] +def append_user_notice(messages: list, text: str) -> None: + """Add an async notice without creating adjacent user messages.""" + block = {"type": "text", "text": text} + if messages and messages[-1].get("role") == "user": + content = messages[-1].get("content", "") + if isinstance(content, list): + messages[-1]["content"] = [*content, block] + else: + messages[-1]["content"] = [ + {"type": "text", "text": str(content)}, + block, + ] + return + messages.append({"role": "user", "content": [block]}) + + +def inject_background_notifications(messages: list) -> int: + notifs = BG.drain_notifications() + if not notifs or not messages: + return 0 + notif_text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + append_user_notice( + messages, + f"\n{notif_text}\n", + ) + return len(notifs) + + def agent_loop(messages: list): while True: # Drain background notifications and inject before the next LLM call. # Merge into the trailing user message when possible to avoid emitting # two consecutive user messages (which is messy for caching/debugging). - notifs = BG.drain_notifications() - if notifs and messages: - notif_text = "\n".join( - f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs - ) - bg_block = { - "type": "text", - "text": f"\n{notif_text}\n", - } - last = messages[-1] - if last["role"] == "user": - if isinstance(last["content"], str): - last["content"] = [ - {"type": "text", "text": last["content"]}, - bg_block, - ] - else: - last["content"] = list(last["content"]) + [bg_block] - else: - messages.append({"role": "user", "content": [bg_block]}) + inject_background_notifications(messages) response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000, diff --git a/agents/s_full.py b/agents/s_full.py index e2f887b5..4da142d3 100644 --- a/agents/s_full.py +++ b/agents/s_full.py @@ -650,6 +650,45 @@ TOOLS = [ ] +def append_user_notice(messages: list, text: str) -> None: + """Add an async notice without creating adjacent user messages.""" + block = {"type": "text", "text": text} + if messages and messages[-1].get("role") == "user": + content = messages[-1].get("content", "") + if isinstance(content, list): + messages[-1]["content"] = [*content, block] + else: + messages[-1]["content"] = [ + {"type": "text", "text": str(content)}, + block, + ] + return + messages.append({"role": "user", "content": [block]}) + + +def inject_pending_notifications(messages: list) -> int: + count = 0 + notifs = BG.drain() + if notifs: + text = "\n".join( + f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs + ) + append_user_notice( + messages, + f"\n{text}\n", + ) + count += len(notifs) + + inbox = BUS.read_inbox("lead") + if inbox: + append_user_notice( + messages, + f"{json.dumps(inbox, indent=2)}", + ) + count += len(inbox) + return count + + # === SECTION: agent_loop === def agent_loop(messages: list): rounds_without_todo = 0 @@ -659,15 +698,8 @@ def agent_loop(messages: list): if estimate_tokens(messages) > TOKEN_THRESHOLD: 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"}) - # s10: check lead inbox - inbox = BUS.read_inbox("lead") - if inbox: - messages.append({"role": "user", "content": f"{json.dumps(inbox, indent=2)}"}) + # s08/s10: fold asynchronous notices into one user turn. + inject_pending_notifications(messages) # LLM call response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, diff --git a/tests/test_s_full_background.py b/tests/test_s_full_background.py index 4bdb10b5..0b66553e 100644 --- a/tests/test_s_full_background.py +++ b/tests/test_s_full_background.py @@ -8,10 +8,11 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = REPO_ROOT / "agents" / "s_full.py" +S08_MODULE_PATH = REPO_ROOT / "agents" / "s08_background_tasks.py" +S_FULL_MODULE_PATH = REPO_ROOT / "agents" / "s_full.py" -def load_s_full_module(temp_cwd: Path): +def load_agent_module(temp_cwd: Path, module_path: Path, module_name: str): fake_anthropic = types.ModuleType("anthropic") class FakeAnthropic: @@ -25,9 +26,9 @@ def load_s_full_module(temp_cwd: Path): previous_anthropic = sys.modules.get("anthropic") previous_dotenv = sys.modules.get("dotenv") previous_cwd = Path.cwd() - spec = importlib.util.spec_from_file_location("s_full_under_test", MODULE_PATH) + spec = importlib.util.spec_from_file_location(module_name, module_path) if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load {MODULE_PATH}") + raise RuntimeError(f"Unable to load {module_path}") module = importlib.util.module_from_spec(spec) sys.modules["anthropic"] = fake_anthropic @@ -49,6 +50,18 @@ def load_s_full_module(temp_cwd: Path): sys.modules["dotenv"] = previous_dotenv +def load_s08_module(temp_cwd: Path): + return load_agent_module( + temp_cwd, + S08_MODULE_PATH, + "s08_background_tasks_under_test", + ) + + +def load_s_full_module(temp_cwd: Path): + return load_agent_module(temp_cwd, S_FULL_MODULE_PATH, "s_full_under_test") + + class BackgroundManagerTests(unittest.TestCase): def test_check_returns_running_placeholder_when_result_is_none(self): with tempfile.TemporaryDirectory() as tmp: @@ -63,5 +76,90 @@ class BackgroundManagerTests(unittest.TestCase): self.assertEqual(manager.check("abc123"), "[running] (running)") +class NotificationInjectionTests(unittest.TestCase): + @staticmethod + def notification(): + return { + "task_id": "bg-1", + "status": "completed", + "result": "BACKGROUND_OK", + } + + def test_string_user_tail_receives_background_block(self): + with tempfile.TemporaryDirectory() as tmp: + module = load_s08_module(Path(tmp)) + module.BG = types.SimpleNamespace( + drain_notifications=lambda: [self.notification()] + ) + messages = [{"role": "user", "content": "original request"}] + + count = module.inject_background_notifications(messages) + + self.assertEqual(count, 1) + self.assertEqual([message["role"] for message in messages], ["user"]) + self.assertEqual( + messages[0]["content"][0], + {"type": "text", "text": "original request"}, + ) + self.assertIn("BACKGROUND_OK", messages[0]["content"][1]["text"]) + + def test_tool_result_user_tail_preserves_result_before_background_block(self): + with tempfile.TemporaryDirectory() as tmp: + module = load_s08_module(Path(tmp)) + module.BG = types.SimpleNamespace( + drain_notifications=lambda: [self.notification()] + ) + tool_result = { + "type": "tool_result", + "tool_use_id": "tool-1", + "content": "tool output", + } + messages = [{"role": "user", "content": [tool_result]}] + + module.inject_background_notifications(messages) + + self.assertEqual([message["role"] for message in messages], ["user"]) + self.assertEqual(messages[0]["content"][0], tool_result) + self.assertIn("BACKGROUND_OK", messages[0]["content"][1]["text"]) + + def test_assistant_tail_gets_one_following_user_message(self): + with tempfile.TemporaryDirectory() as tmp: + module = load_s08_module(Path(tmp)) + module.BG = types.SimpleNamespace( + drain_notifications=lambda: [self.notification()] + ) + messages = [{"role": "assistant", "content": "working"}] + + module.inject_background_notifications(messages) + + self.assertEqual( + [message["role"] for message in messages], + ["assistant", "user"], + ) + self.assertIn("BACKGROUND_OK", messages[1]["content"][0]["text"]) + + def test_full_agent_merges_background_and_inbox_into_one_user_turn(self): + with tempfile.TemporaryDirectory() as tmp: + module = load_s_full_module(Path(tmp)) + module.BG = types.SimpleNamespace( + drain=lambda: [self.notification()] + ) + module.BUS = types.SimpleNamespace( + read_inbox=lambda recipient: [ + {"from": "reviewer", "to": recipient, "content": "INBOX_OK"} + ] + ) + messages = [{"role": "user", "content": "original request"}] + + count = module.inject_pending_notifications(messages) + + self.assertEqual(count, 2) + self.assertEqual([message["role"] for message in messages], ["user"]) + blocks = messages[0]["content"] + self.assertEqual(blocks[0], {"type": "text", "text": "original request"}) + self.assertIn("BACKGROUND_OK", blocks[1]["text"]) + self.assertIn("INBOX_OK", blocks[2]["text"]) + + if __name__ == "__main__": unittest.main()