From 757e84482f7319f039512a71e9497a770e8541ed Mon Sep 17 00:00:00 2001
From: huanghuang358 <178640467+huanghuang358@users.noreply.github.com>
Date: Sun, 19 Apr 2026 12:53:46 +0800
Subject: [PATCH 1/2] fix(s08): avoid consecutive user messages when draining
background notifications
The previous implementation always appended a new user message for background
results regardless of the trailing message's role. After a tool round-trip
(or right after the user query), the messages list could end up with two
adjacent user messages, which is messy for prompt caching, token accounting
and stricter API gateways.
Now we merge the background-results block into the trailing user message
when its role is already user, and only append a fresh user message
otherwise. tool_result blocks still immediately follow their tool_use, so
the protocol contract is preserved.
---
agents/s08_background_tasks.py | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/agents/s08_background_tasks.py b/agents/s08_background_tasks.py
index 390a7778..410732b1 100644
--- a/agents/s08_background_tasks.py
+++ b/agents/s08_background_tasks.py
@@ -187,13 +187,29 @@ TOOLS = [
def agent_loop(messages: list):
while True:
- # Drain background notifications and inject as system message before LLM call
+ # 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
)
- messages.append({"role": "user", "content": f"\n{notif_text}\n"})
+ 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]})
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
From a2e5be85cdabfb941e76020edf2197d287a4aace Mon Sep 17 00:00:00 2001
From: Haoran
Date: Mon, 17 Aug 2026 14:25:07 +0800
Subject: [PATCH 2/2] 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()