fix(computer-use): wait_for_human returns no_takeover when nobody answers the handoff
wait_for_release polled until the full timeout (10 min by default) even
when the holder never left AGENT, so an unseen request_handoff blocked the
turn instead of letting the model chase the user in chat. After `grace`
seconds (param, default 60, capped at the timeout) with the handoff still
pending and no human holding, return {ok: false, code: no_takeover}. Once
a human holds the screen the full timeout still applies to the hand-back.
(cherry picked from commit 1402af036044a7191ec885fc8fa5c24206880909)
This commit is contained in:
38
tests/tools/test_computer_use_handoff_wait.py
Normal file
38
tests/tools/test_computer_use_handoff_wait.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""wait_for_human answers an unanswered handoff early instead of blocking the whole timeout, and still
|
||||
waits the full timeout once a human actually holds the screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.bot_desktop import lease
|
||||
from tools.computer_use.handoff import handle_handoff
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_lease():
|
||||
lease._reset_for_tests()
|
||||
yield
|
||||
lease._reset_for_tests()
|
||||
|
||||
|
||||
def test_wait_for_human_returns_no_takeover_when_nobody_answers_but_waits_out_a_real_takeover():
|
||||
handle_handoff("request_handoff", {"reason": "log in"})
|
||||
t0 = time.monotonic()
|
||||
res = json.loads(handle_handoff("wait_for_human", {"seconds": 30, "grace": 0.2}))
|
||||
assert res["code"] == "no_takeover" and res["state"]["pending_handoff"] == "log in"
|
||||
assert time.monotonic() - t0 < 10, "an unanswered request must not run the full timeout"
|
||||
|
||||
# A human takes over inside the grace window and hands back later: the wait outlives the grace.
|
||||
def _take_then_release():
|
||||
time.sleep(0.1)
|
||||
lease.acquire("viewer-1")
|
||||
time.sleep(0.6)
|
||||
lease.release("viewer-1")
|
||||
threading.Thread(target=_take_then_release, daemon=True).start()
|
||||
res = json.loads(handle_handoff("wait_for_human", {"seconds": 30, "grace": 0.3}))
|
||||
assert res["ok"] and res["state"]["holder"] == lease.AGENT
|
||||
@@ -190,11 +190,19 @@ def wait_for_release(*, timeout: float, profile_key: Optional[str] = None) -> bo
|
||||
"""Block until the agent holds the lease (and no handoff is pending) or ``timeout`` elapses.
|
||||
True when control is back with the agent. Polls the file so a release made by another process
|
||||
is seen; the local Condition just shortens the wait for same-process transitions."""
|
||||
return _wait_until(lambda lease: lease.holder == AGENT and lease.pending_handoff is None, timeout, profile_key)
|
||||
|
||||
|
||||
def wait_for_takeover_or_release(*, timeout: float, profile_key: Optional[str] = None) -> bool:
|
||||
"""False when, after ``timeout``, the agent still holds with a handoff pending: nobody answered."""
|
||||
return _wait_until(lambda lease: lease.holder == HUMAN or lease.pending_handoff is None, timeout, profile_key)
|
||||
|
||||
|
||||
def _wait_until(done: Callable[[Lease], bool], timeout: float, profile_key: Optional[str]) -> bool:
|
||||
path = _path(profile_key)
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
lease = _read(path)
|
||||
if lease.holder == AGENT and lease.pending_handoff is None:
|
||||
if done(_read(path)):
|
||||
return True
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
|
||||
@@ -18,6 +18,7 @@ from tools.bot_desktop import lease as _lease
|
||||
HANDOFF_ACTIONS = frozenset({"request_handoff", "wait_for_human"})
|
||||
_DEFAULT_WAIT_SECONDS = 600.0
|
||||
_MAX_WAIT_SECONDS = 1800.0
|
||||
_DEFAULT_GRACE_SECONDS = 60.0
|
||||
|
||||
|
||||
def handle_handoff(action: str, args: Dict[str, Any]) -> str:
|
||||
@@ -31,6 +32,15 @@ def handle_handoff(action: str, args: Dict[str, Any]) -> str:
|
||||
"computer_use action='wait_for_human' to block until they hand control back and "
|
||||
"re-capture before continuing — the screen state is whatever they left."})
|
||||
timeout = min(_MAX_WAIT_SECONDS, max(1.0, float(args.get("seconds") or _DEFAULT_WAIT_SECONDS)))
|
||||
grace = min(timeout, max(0.0, float(args.get("grace") or _DEFAULT_GRACE_SECONDS)))
|
||||
# Nobody has taken over yet: give them `grace` to click Take over, then return so the model can chase
|
||||
# the user in chat instead of blocking the whole timeout on a request nobody saw. Once a human holds
|
||||
# the screen, wait the full timeout for the hand-back.
|
||||
if not _lease.wait_for_takeover_or_release(timeout=grace):
|
||||
state = _lease.get().as_dict()
|
||||
return json.dumps({"ok": False, "action": action, "code": "no_takeover", "state": state,
|
||||
"error": f"Nobody took over within {grace:.0f}s. Ask the user in chat to open Bots > Screen and "
|
||||
"click Take over, then call wait_for_human again."})
|
||||
released = _lease.wait_for_release(timeout=timeout)
|
||||
state = _lease.get().as_dict()
|
||||
if released:
|
||||
|
||||
Reference in New Issue
Block a user