mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
fix: align Discord approval buttons with request timeout
This commit is contained in:
1 parent
ee7e0f5172
commit
4f86e97791
13 files changed
+284
-24
No files matched your search
@@ -725,10 +725,14 @@ class TurnRunner(GatewayTurnProgressMixin, GatewaySessionAgentMixin):
|
||||
# Check the *class*, not the instance — MagicMock auto-creates attributes in tests.
|
||||
if _renders_exec_approval_buttons(type(adapter)):
|
||||
try:
|
||||
approval_metadata = ctx._status_thread_metadata
|
||||
if "timeout_s" in approval_data:
|
||||
approval_metadata = dict(approval_metadata or {})
|
||||
approval_metadata["_approval_request_id"] = approval_data.get("request_id")
|
||||
fut = self._schedule(
|
||||
adapter.send_exec_approval(
|
||||
chat_id=ctx._status_chat_id, command=cmd, session_key=ctx.session_key or "",
|
||||
description=desc, metadata=ctx._status_thread_metadata, **flags,
|
||||
description=desc, metadata=approval_metadata, **flags,
|
||||
),
|
||||
"send_exec_approval scheduling error",
|
||||
)
|
||||
|
||||
@@ -37,7 +37,9 @@ def register_timeout_notice(
|
||||
session_key = runner._ctx.session_key or ""
|
||||
if not request_id or not session_key:
|
||||
return
|
||||
timeout_s = approval_timeout_seconds()
|
||||
timeout_s = approval_data.get("timeout_s")
|
||||
if timeout_s is None:
|
||||
timeout_s = approval_timeout_seconds()
|
||||
|
||||
def settle(reason: str) -> None:
|
||||
if reason != "timeout":
|
||||
@@ -73,7 +75,10 @@ async def _post_timeout_notice(ctx, command: str, card_message_id: Optional[str]
|
||||
|
||||
async def _edit_card(adapter, chat_id: str, message_id: str, content: str) -> bool:
|
||||
"""Edit the card in place (drops the buttons on platforms whose edit replaces the markup)."""
|
||||
edit: Optional[Callable[..., Any]] = getattr(adapter, "edit_message", None)
|
||||
expire = getattr(type(adapter), "expire_exec_approval", None)
|
||||
edit: Optional[Callable[..., Any]] = (
|
||||
adapter.expire_exec_approval if callable(expire) else getattr(adapter, "edit_message", None)
|
||||
)
|
||||
if edit is None:
|
||||
return False
|
||||
try:
|
||||
|
||||
@@ -5694,8 +5694,23 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
|
||||
+ len(self._approval_mention_content() or "") + 1)
|
||||
return max(0, self.MAX_MESSAGE_LENGTH - fixed)
|
||||
|
||||
async def expire_exec_approval(self, chat_id: str, message_id: str, content: str) -> SendResult:
|
||||
"""Remove Discord's components when the backend approval expires."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
msg = channel.get_partial_message(int(message_id))
|
||||
content = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)[0]
|
||||
await msg.edit(content=content, view=None)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as exc:
|
||||
logger.warning("[Discord] Failed to expire approval card: %s", exc, exc_info=True)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
|
||||
"""Send an approval with content as its canonical payload and an embed for state."""
|
||||
view_holder = []
|
||||
def _build(_channel):
|
||||
content = prompt.text
|
||||
mention_content = self._approval_mention_content()
|
||||
@@ -5712,7 +5727,9 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
|
||||
allowed_role_ids=self._allowed_role_ids, require_admin=require_admin,
|
||||
admin_user_ids=admin_user_ids, allow_permanent="always" in choices,
|
||||
allow_session="session" in choices, smart_denied=prompt.smart_denied,
|
||||
request_id=(prompt.metadata or {}).get("_approval_request_id"),
|
||||
)
|
||||
view_holder.append(view)
|
||||
send_kwargs: dict[str, Any] = {"content": content, "embed": embed, "view": view}
|
||||
if mention_content:
|
||||
allowed_mentions_cls = getattr(discord, "AllowedMentions", None)
|
||||
@@ -5721,7 +5738,18 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
|
||||
users=True, roles=False, everyone=False, replied_user=False,
|
||||
)
|
||||
return send_kwargs, view
|
||||
return await self._send_prompt(prompt.chat_id, prompt.metadata, _build)
|
||||
result = await self._send_prompt(prompt.chat_id, prompt.metadata, _build)
|
||||
if result.success and view_holder and view_holder[0].request_id:
|
||||
from tools.approval import observe_gateway_settle
|
||||
view = view_holder[0]
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def retire(_reason):
|
||||
loop.call_soon_threadsafe(lambda: loop.create_task(view.retire()))
|
||||
|
||||
if not observe_gateway_settle(prompt.session_key, view.request_id, retire):
|
||||
await view.retire()
|
||||
return result
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
@@ -6437,9 +6465,14 @@ def _define_discord_view_classes() -> None:
|
||||
self, session_key: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None,
|
||||
require_admin: bool = False, admin_user_ids: Optional[set] = None,
|
||||
allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False,
|
||||
request_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout())
|
||||
# The backend starts waiting only after delivery. Its settlement retires
|
||||
# this exact card; a separate view timer could expire it prematurely.
|
||||
timeout = None if request_id else _read_discord_prompt_timeout()
|
||||
super().__init__(allowed_user_ids, allowed_role_ids, timeout=timeout)
|
||||
self.session_key = session_key
|
||||
self.request_id = request_id
|
||||
self.require_admin = require_admin
|
||||
self.admin_user_ids = {str(a).strip() for a in (admin_user_ids or set()) if str(a).strip()}
|
||||
self._localize_buttons(
|
||||
@@ -6475,6 +6508,19 @@ def _define_discord_view_classes() -> None:
|
||||
)
|
||||
return False
|
||||
|
||||
async def retire(self):
|
||||
"""Disable a card when its particular backend request is no longer pending."""
|
||||
if self.resolved:
|
||||
return
|
||||
self.resolved = True
|
||||
self._disable_all()
|
||||
if self._message:
|
||||
try:
|
||||
await self._message.edit(view=self)
|
||||
except Exception:
|
||||
logger.debug("[Discord] Could not retire approval card", exc_info=True)
|
||||
self.stop()
|
||||
|
||||
async def _resolve(self, interaction: discord.Interaction, choice: str, color: discord.Color, label_key: str):
|
||||
"""Resolve the approval via the gateway approval queue and update the embed."""
|
||||
if not await self._gate(
|
||||
@@ -6488,7 +6534,7 @@ def _define_discord_view_classes() -> None:
|
||||
# wait timed out (count == 0) must not claim "Approved".
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
count = resolve_gateway_approval(self.session_key, choice)
|
||||
count = resolve_gateway_approval(self.session_key, choice, request_id=self.request_id)
|
||||
logger.info(
|
||||
"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||||
count, self.session_key, choice, interaction.user.display_name,
|
||||
|
||||
@@ -24,7 +24,7 @@ def _read_dm_role_auth_guild() -> Optional[int]:
|
||||
return guild_id if guild_id > 0 else None
|
||||
|
||||
|
||||
# Default timeout for Discord button views when ``approvals.discord_prompt_timeout`` is unset;
|
||||
# Default timeout for non-exec Discord button views when ``approvals.discord_prompt_timeout`` is unset;
|
||||
# Discord interaction tokens expire at ~15 minutes, so 900s is the practical ceiling.
|
||||
_DISCORD_PROMPT_TIMEOUT_DEFAULT = 300
|
||||
_DISCORD_PROMPT_TIMEOUT_MIN = 30
|
||||
@@ -32,7 +32,7 @@ _DISCORD_PROMPT_TIMEOUT_MAX = 900
|
||||
|
||||
|
||||
def _read_discord_prompt_timeout() -> int:
|
||||
"""Timeout (seconds) for Discord button views from ``approvals.discord_prompt_timeout``
|
||||
"""Timeout (seconds) for non-exec Discord button views from ``approvals.discord_prompt_timeout``
|
||||
(default 300), clamped to [MIN, MAX] so a typo can't make prompts vanish or outlive tokens."""
|
||||
raw: Any = None
|
||||
try:
|
||||
|
||||
@@ -271,6 +271,8 @@ def _ensure_discord_mock() -> None:
|
||||
def __init__(self, timeout=None):
|
||||
self.timeout = timeout
|
||||
self.children = []
|
||||
def stop(self):
|
||||
pass
|
||||
def add_item(self, item):
|
||||
self.children.append(item)
|
||||
def clear_items(self):
|
||||
|
||||
@@ -60,3 +60,18 @@ async def test_text_prompt_is_never_edited_in_place():
|
||||
assert [kind for kind, _ in calls] == ["send"]
|
||||
await settle_mod._post_timeout_notice(ctx, "ls", "card-9", 300)
|
||||
assert ("edit", "card-9") in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_notice_uses_platform_approval_expiry():
|
||||
calls = []
|
||||
|
||||
class _Adapter:
|
||||
async def expire_exec_approval(self, chat_id, message_id, content):
|
||||
calls.append((chat_id, message_id, content))
|
||||
return SimpleNamespace(success=True)
|
||||
|
||||
ctx = SimpleNamespace(_status_adapter=_Adapter(), _status_chat_id="c", _status_thread_metadata=None)
|
||||
await settle_mod._post_timeout_notice(ctx, "ls", "card-9", 3600)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][:2] == ("c", "card-9")
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -65,6 +66,97 @@ async def test_exec_approval_content_stays_within_discord_cap_for_long_reason():
|
||||
assert "xxx" in sent["content"] # the command preview is not starved to zero
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_view_uses_the_waiting_request_timeout():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
sent = _capture_channel(adapter)
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="555", command="ls", session_key="agent:other:discord:555",
|
||||
metadata={"_approval_request_id": "request-1"},
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert sent["view"].timeout is None
|
||||
assert sent["view"].request_id == "request-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_card_cannot_resolve_a_different_pending_request(monkeypatch):
|
||||
from plugins.platforms.discord import adapter as discord_adapter
|
||||
from tools import approval
|
||||
from tools.approval_gateway_wait import _ApprovalEntry
|
||||
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
sent = _capture_channel(adapter)
|
||||
session = "agent:other:discord:555"
|
||||
await adapter.send_exec_approval(
|
||||
chat_id="555", command="ls", session_key=session,
|
||||
metadata={"_approval_request_id": "expired"},
|
||||
)
|
||||
other = _ApprovalEntry({"request_id": "new", "command": "rm other"})
|
||||
monkeypatch.setitem(approval._gateway_queues, session, [other])
|
||||
view = sent["view"]
|
||||
monkeypatch.setattr(view, "_gate", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(view, "_finalize_embed", AsyncMock())
|
||||
monkeypatch.setattr(discord_adapter.discord, "Color", SimpleNamespace(dark_grey=lambda: 0))
|
||||
interaction = SimpleNamespace(user=SimpleNamespace(display_name="tester"))
|
||||
|
||||
await view._resolve(interaction, "once", 1, "gateway.exec_approval.action_once")
|
||||
|
||||
assert not other.event.is_set()
|
||||
assert approval._gateway_queues[session] == [other]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_approval_removes_discord_components():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
message = SimpleNamespace(edit=AsyncMock())
|
||||
channel = SimpleNamespace(get_partial_message=lambda _id: message)
|
||||
adapter._client = SimpleNamespace(get_channel=lambda _id: channel)
|
||||
|
||||
result = await adapter.expire_exec_approval("555", "1234", "Approval timed out")
|
||||
|
||||
assert result.success
|
||||
assert message.edit.await_args.kwargs["view"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("expire_during_send", [False, True])
|
||||
async def test_card_retires_when_backend_request_ends(monkeypatch, expire_during_send):
|
||||
from tools import approval
|
||||
from tools.approval_gateway_wait import _ApprovalEntry
|
||||
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
session = "agent:other:discord:555"
|
||||
entry = _ApprovalEntry({"request_id": "request-1", "command": "ls"})
|
||||
monkeypatch.setitem(approval._gateway_queues, session, [entry])
|
||||
message = SimpleNamespace(id=1234, edit=AsyncMock())
|
||||
sent = {}
|
||||
|
||||
async def send(**kwargs):
|
||||
sent.update(kwargs)
|
||||
if expire_during_send:
|
||||
approval._gateway_queues.pop(session)
|
||||
return message
|
||||
|
||||
channel = SimpleNamespace(send=send)
|
||||
adapter._client = SimpleNamespace(get_channel=lambda _id: channel)
|
||||
await adapter.send_exec_approval(
|
||||
chat_id="555", command="ls", session_key=session,
|
||||
metadata={"_approval_request_id": "request-1"},
|
||||
)
|
||||
view = sent["view"]
|
||||
if not expire_during_send:
|
||||
assert not view.resolved
|
||||
assert len(entry.observers) == 1
|
||||
entry.observers[0]("timeout")
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert view.resolved
|
||||
message.edit.assert_awaited_once()
|
||||
|
||||
|
||||
|
||||
def _embed_text(embed):
|
||||
# Fields are attribute objects on discord.py and dicts on the test stub.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for the configurable Discord interactive-view timeout.
|
||||
|
||||
Previously hardcoded to 300s on ExecApprovalView, SlashConfirmView,
|
||||
UpdatePromptView, and ClarifyChoiceView. Now reads
|
||||
SlashConfirmView, UpdatePromptView, and ClarifyChoiceView read
|
||||
``approvals.discord_prompt_timeout`` with the same 300s default, clamped to
|
||||
``[_DISCORD_PROMPT_TIMEOUT_MIN, _DISCORD_PROMPT_TIMEOUT_MAX]`` so a typo
|
||||
can't make prompts disappear (too short) or outlive Discord's 15-min
|
||||
|
||||
@@ -107,6 +107,16 @@ def test_timeout_edits_the_card_to_say_the_command_did_not_run(pending_entry, tm
|
||||
assert adapter.card_metadata == [{"thread_id": "t1"}] # no notify: A2A's turn-final marker
|
||||
|
||||
|
||||
def test_prompt_carries_request_identity(pending_entry):
|
||||
adapter = _ButtonAdapter()
|
||||
data = dict(pending_entry.data, timeout_s=3600)
|
||||
_runner(adapter)._approval_notify_sync(data)
|
||||
|
||||
assert adapter.card_metadata == [{
|
||||
"thread_id": "t1", "_approval_request_id": pending_entry.data["request_id"],
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text_fallback", [False, True])
|
||||
def test_timeout_falls_back_to_a_new_message_when_the_card_cannot_be_edited(pending_entry, text_fallback):
|
||||
adapter = _PlainAdapter() if text_fallback else _ButtonAdapter(editable=False)
|
||||
|
||||
@@ -37,7 +37,7 @@ def _run_leader_then_follower(monkeypatch, notify_cb, *, leader_poll=_leader_nev
|
||||
leader_enqueued = threading.Event()
|
||||
results: dict = {}
|
||||
|
||||
def poll(event, session_key, *, interrupt_log):
|
||||
def poll(event, session_key, *, interrupt_log, timeout_s=None):
|
||||
if "Coalesced" in interrupt_log:
|
||||
follower_waiting.set()
|
||||
return real_poll(event, session_key, interrupt_log=interrupt_log)
|
||||
|
||||
@@ -23,8 +23,12 @@ def test_choice_landing_after_the_deadline_check_is_resolved(monkeypatch):
|
||||
hooks: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: hooks.append((name, kw)))
|
||||
|
||||
def deadline_passed_then_user_answered(event, session_key, *, interrupt_log):
|
||||
settles = []
|
||||
|
||||
def deadline_passed_then_user_answered(event, session_key, *, interrupt_log, timeout_s=None):
|
||||
# The poll loop has already decided "timeout"; the user's /approve lands before the entry leaves the queue.
|
||||
request_id = mod._gateway_queues[session_key][0].data["request_id"]
|
||||
assert mod.register_gateway_settle(session_key, request_id, settles.append)
|
||||
assert mod.resolve_gateway_approval(session_key, "once") == 1
|
||||
return "timeout"
|
||||
|
||||
@@ -34,13 +38,14 @@ def test_choice_landing_after_the_deadline_check_is_resolved(monkeypatch):
|
||||
|
||||
assert decision == {"resolved": True, "choice": "once", "reason": None}
|
||||
assert hooks[-1][0] == "post_approval_response" and hooks[-1][1]["choice"] == "once"
|
||||
assert settles == ["resolved"]
|
||||
assert SESSION_KEY not in mod._gateway_queues
|
||||
|
||||
|
||||
def test_plain_timeout_still_reports_unresolved(monkeypatch):
|
||||
_clear()
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
|
||||
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log: "timeout")
|
||||
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log, timeout_s=None: "timeout")
|
||||
|
||||
decision = wait_mod._await_gateway_decision(SESSION_KEY, lambda data: None, APPROVAL)
|
||||
|
||||
@@ -49,6 +54,70 @@ def test_plain_timeout_still_reports_unresolved(monkeypatch):
|
||||
assert mod.resolve_gateway_approval(SESSION_KEY, "once") == 0
|
||||
|
||||
|
||||
def test_multiple_observers_and_settle_hook_run_on_expiry(monkeypatch):
|
||||
_clear()
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
|
||||
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log, timeout_s=None: "timeout")
|
||||
outcomes = []
|
||||
|
||||
def notify(data):
|
||||
rid = data["request_id"]
|
||||
assert mod.register_gateway_settle(SESSION_KEY, rid, lambda reason: outcomes.append(("notice", reason)))
|
||||
assert mod.observe_gateway_settle(SESSION_KEY, rid, lambda reason: outcomes.append(("card", reason)))
|
||||
|
||||
wait_mod._await_gateway_decision(SESSION_KEY, notify, APPROVAL)
|
||||
assert outcomes == [("notice", "timeout"), ("card", "timeout")]
|
||||
|
||||
|
||||
def test_prompt_and_wait_use_same_profile_timeout_when_config_changes(monkeypatch):
|
||||
_clear()
|
||||
current = {"timeout": 3600}
|
||||
monkeypatch.setattr(wait_mod._ctx, "_get_approval_timeout", lambda: current["timeout"])
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
|
||||
observed = {}
|
||||
|
||||
def notify(data):
|
||||
observed["prompt_timeout"] = data["timeout_s"]
|
||||
current["timeout"] = 300
|
||||
|
||||
def poll(event, session_key, *, interrupt_log, timeout_s=None):
|
||||
observed["wait_timeout"] = timeout_s
|
||||
return "timeout"
|
||||
|
||||
monkeypatch.setattr(wait_mod, "_poll_event", poll)
|
||||
decision = wait_mod._await_gateway_decision(SESSION_KEY, notify, APPROVAL)
|
||||
|
||||
assert decision["resolved"] is False
|
||||
assert observed == {"prompt_timeout": 3600, "wait_timeout": 3600}
|
||||
|
||||
|
||||
def test_gateway_approval_captures_routed_profiles_timeout(tmp_path, monkeypatch):
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
_clear()
|
||||
default = tmp_path / "default"
|
||||
routed = tmp_path / "routed"
|
||||
for home, timeout in ((default, 300), (routed, 3600)):
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text(f"approvals:\n timeout: {timeout}\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(default))
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
|
||||
observed = []
|
||||
|
||||
def poll(event, session_key, *, interrupt_log, timeout_s=None):
|
||||
observed.append(timeout_s)
|
||||
return "timeout"
|
||||
|
||||
monkeypatch.setattr(wait_mod, "_poll_event", poll)
|
||||
for home in (default, routed):
|
||||
token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
wait_mod._await_gateway_decision(SESSION_KEY, lambda data: observed.append(data["timeout_s"]), APPROVAL)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert observed == [300, 300, 3600, 3600]
|
||||
|
||||
|
||||
def test_resolve_commits_the_choice_before_releasing_the_approval_lock(monkeypatch):
|
||||
"""``_drop_entry`` reads ``entry.result`` and leaves the queue in one ``_lock`` section, which only
|
||||
closes the race if ``resolve_gateway_approval`` commits ``result``/``event`` INSIDE the section that
|
||||
@@ -81,7 +150,7 @@ def test_withdrawn_entry_settles_with_a_wire_reason(monkeypatch):
|
||||
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
|
||||
reasons: list[str] = []
|
||||
|
||||
def torn_down(event, session_key, *, interrupt_log):
|
||||
def torn_down(event, session_key, *, interrupt_log, timeout_s=None):
|
||||
mod.register_gateway_settle(session_key, mod._gateway_queues[session_key][0].data["request_id"], reasons.append)
|
||||
mod.clear_session(session_key)
|
||||
return "set"
|
||||
|
||||
@@ -210,6 +210,16 @@ def register_gateway_settle(session_key: str, request_id: str, settle) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def observe_gateway_settle(session_key: str, request_id: str, observer) -> bool:
|
||||
"""Observe a pending approval without replacing its existing settle hook."""
|
||||
with _lock:
|
||||
for entry in _gateway_queues.get(session_key, []):
|
||||
if entry.data.get("request_id") == request_id:
|
||||
entry.observers.append(observer)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ack_gateway_approval(session_key: str, request_id: str) -> bool:
|
||||
"""Record that a client received a particular pending approval request."""
|
||||
with _lock:
|
||||
|
||||
@@ -24,7 +24,7 @@ logger = logging.getLogger("tools.approval")
|
||||
|
||||
class _ApprovalEntry:
|
||||
"""One pending dangerous-command approval inside a gateway session."""
|
||||
__slots__ = ("acknowledged", "cancelled", "data", "event", "expired", "reason", "result", "settle")
|
||||
__slots__ = ("acknowledged", "cancelled", "data", "event", "expired", "observers", "reason", "result", "settle")
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self.event = threading.Event()
|
||||
@@ -34,6 +34,7 @@ class _ApprovalEntry:
|
||||
# Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from
|
||||
# another client): the tui_gateway withdraws its open server→client request through it.
|
||||
self.settle = None
|
||||
self.observers = []
|
||||
self.result: str | None = None # "once"|"session"|"always"|"deny"
|
||||
# Free-text reason from ``/deny <reason>`` so the agent can adapt, not just hear "denied".
|
||||
self.reason: str | None = None
|
||||
@@ -44,7 +45,8 @@ class _ApprovalEntry:
|
||||
self.expired = False
|
||||
|
||||
|
||||
def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str) -> str:
|
||||
def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str,
|
||||
timeout_s: int | None = None) -> str:
|
||||
"""Wait on *event* until it fires, the turn is interrupted, or approvals.timeout
|
||||
elapses; returns ``"set"`` | ``"interrupted"`` | ``"timeout"``. Polls in ~1s
|
||||
slices so activity heartbeats reach the agent's inactivity tracker every ~10s —
|
||||
@@ -57,7 +59,7 @@ def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str)
|
||||
per-thread interrupt-cause channel (``get_interrupt_reason()``, a trusted fixed
|
||||
category), never inferred from message text, so the caller can report a
|
||||
withdrawn prompt without inventing a user refusal."""
|
||||
deadline = time.monotonic() + max(_ctx._get_approval_timeout(), 0)
|
||||
deadline = time.monotonic() + max(_ctx._get_approval_timeout() if timeout_s is None else timeout_s, 0)
|
||||
heartbeat = activity_heartbeat("waiting for user approval")
|
||||
with human_wait_window(session_key):
|
||||
while True:
|
||||
@@ -199,6 +201,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
|
||||
if not queue:
|
||||
_approval._gateway_queues.pop(session_key, None)
|
||||
settle, entry.settle = entry.settle, None
|
||||
observers, entry.observers = entry.observers, []
|
||||
if choice is None and state in ("timeout", "notify_failed"):
|
||||
# Coalesced followers block on this event: wake them with the leader's outcome rather than
|
||||
# leaving them waiting their own full timeout on a prompt that no longer exists.
|
||||
@@ -206,18 +209,21 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
|
||||
if state == "notify_failed":
|
||||
entry.cancelled = "the approval prompt could not be delivered"
|
||||
entry.event.set()
|
||||
if settle is not None:
|
||||
if settle is not None or observers:
|
||||
# ``request.cancel`` carries a RequestCancelReason: a choice committed from another surface is
|
||||
# ``resolved``; a withdrawn entry (woken with no choice — session torn down, turn ended, client
|
||||
# cannot answer) is ``session_closed``; never the raw poll-state token "set".
|
||||
if state == "set":
|
||||
if state == "timeout" and choice is not None:
|
||||
reason = "resolved"
|
||||
elif state == "set":
|
||||
reason = "resolved" if choice is not None else "session_closed"
|
||||
else:
|
||||
reason = state
|
||||
try:
|
||||
settle(reason)
|
||||
except Exception:
|
||||
logger.debug("approval settle hook failed", exc_info=True)
|
||||
for callback in ([settle] if settle is not None else []) + observers:
|
||||
try:
|
||||
callback(reason)
|
||||
except Exception:
|
||||
logger.debug("approval settle hook failed", exc_info=True)
|
||||
return choice
|
||||
|
||||
from tools.human_input_hooks import human_input_request
|
||||
@@ -226,6 +232,8 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
|
||||
_ctx._fire_approval_hook("pre_approval_request", **payload)
|
||||
# Bridges sync agent thread → async gateway.
|
||||
try:
|
||||
timeout_s = _ctx._get_approval_timeout()
|
||||
entry.data["timeout_s"] = timeout_s
|
||||
notify_cb(dict(entry.data))
|
||||
approval_published()
|
||||
except Exception as exc:
|
||||
@@ -235,7 +243,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
|
||||
human.outcome = "notify_failed"
|
||||
return {"resolved": False, "choice": None, "notify_failed": True}
|
||||
|
||||
state = _poll_event(entry.event, session_key,
|
||||
state = _poll_event(entry.event, session_key, timeout_s=timeout_s,
|
||||
interrupt_log="Approval wait interrupted — returning deny for session %s")
|
||||
cancelled = _cancel_cause(state, entry)
|
||||
if state == "interrupted":
|
||||
|
||||
Reference in new issue
Block a user