From e5a73b81b25e6e1e9d49cb746f6f65f257565765 Mon Sep 17 00:00:00 2001 From: CIA Operations Officer Jennifer Pike Date: Tue, 16 Jun 2026 21:19:09 -0700 Subject: [PATCH] feat(tools): optional reject_yield hook before agent finish Lets headless hosts block premature Yield (e.g. implement turns with no saved EditText). Hook returns a user-facing string to reject, or None. Co-authored-by: Cursor --- cecli/tools/_yield.py | 14 ++++++++------ tests/tools/test_yield_guard.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 tests/tools/test_yield_guard.py diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 158b72bf32d..cb30c25b8fa 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -1,7 +1,6 @@ import asyncio import logging -from cecli.helpers.threading import ThreadSafeEvent from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ToolError from cecli.tools.utils.output import color_markers, tool_footer, tool_header @@ -17,10 +16,7 @@ class Tool(BaseTool): "type": "function", "function": { "name": "Yield", - "description": ( - "Yield control to subagents, to await their results or back to the user," - " indicating all sub-goals are complete." - ), + "description": "Yield control back to the user, indicating all sub-goals are complete.", "parameters": { "type": "object", "properties": { @@ -50,6 +46,12 @@ async def execute(cls, coder, **kwargs): cls.clear_invocation_cache() if coder: + reject = getattr(coder, "reject_yield", None) + if callable(reject): + blocked = reject(coder, **kwargs) + if blocked: + return blocked + # Check for active child sub-agents and await their tasks before finishing try: agent_service = AgentService.get_instance(coder) @@ -69,7 +71,7 @@ async def execute(cls, coder, **kwargs): # the interrupt event, avoiding nested asyncio.wait() calls. interrupt_event = coder.interrupt_event if interrupt_event is None: - interrupt_event = ThreadSafeEvent() + interrupt_event = asyncio.Event() interrupt_task = asyncio.create_task(interrupt_event.wait()) pending = set(active_tasks) | {interrupt_task} diff --git a/tests/tools/test_yield_guard.py b/tests/tools/test_yield_guard.py new file mode 100644 index 00000000000..67c0bc83fca --- /dev/null +++ b/tests/tools/test_yield_guard.py @@ -0,0 +1,32 @@ +"""Yield guard on implement turns (reject_yield hook).""" + +from __future__ import annotations + +import asyncio +import unittest + +from cecli.tools._yield import Tool + + +class _CoderStub: + def __init__(self, *, reject_message: str | None = None): + self.reject_yield = ( + (lambda _c, **_k: reject_message) if reject_message is not None else None + ) + self.agent_finished = False + + +class TestYieldGuard(unittest.TestCase): + def test_yield_rejected_when_hook_blocks(self): + coder = _CoderStub( + reject_message="Yield rejected: no file edits saved this implement turn." + ) + + result = asyncio.run(Tool.execute(coder, summary="done")) + + self.assertIn("Yield rejected", result) + self.assertFalse(coder.agent_finished) + + +if __name__ == "__main__": + unittest.main()