Skip to content

Commit c8d8cc3

Browse files
wtj-0527Lux
andauthored
fix exact Gateway approval waiter settlement (#2681)
* fix interrupt pending group approvals * docs link interrupt approval fix to PR * fix interrupt approval generation isolation * fix exact gateway approval waiter settlement * docs link gateway waiter fix PR --------- Co-authored-by: Lux <wangw9475@agent.qq.com>
1 parent ab05bb4 commit c8d8cc3

9 files changed

Lines changed: 481 additions & 17 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
date: 2026-08-22
3+
pr: 2677
4+
feature: Group Agent interrupt approval settlement
5+
impact: Interrupting an exact Group Agent run generation now denies its pending approvals in both the runtime and browser while leaving other runs untouched.
6+
---
7+
8+
Approval cancellation is idempotent and uses deny-only test commands so interrupted work cannot be approved accidentally.
9+
Approval routes and Agent Bridge waiters are bound to the exact Session + run generation; legacy or malformed empty generations are never claimed by an interrupt, and a later run in the same Session remains isolated.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
date: 2026-08-22
3+
pr: 2681
4+
feature: Exact Gateway approval waiter settlement
5+
impact: Interrupting a Group Agent run now denies its exact Hermes Gateway waiter by Runtime request ID without consuming another generation's waiter from the same Session.
6+
---
7+
8+
Gateway approval notifications retain the Runtime `request_id` beside their exact Session and run generation. Interrupt and user-response paths pass that identity back to Hermes Agent, while missing request IDs fail closed instead of falling back to Session FIFO.

packages/server/src/services/hermes/agent-bridge/python/bridge_pool.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ def __init__(self) -> None:
185185
self._lock = threading.RLock()
186186
self._db = SessionDbHolder()
187187
self._approval_requests: dict[str, queue.Queue[str]] = {}
188-
self._gateway_approval_requests: dict[str, str] = {}
188+
self._approval_request_generations: dict[str, tuple[str, str]] = {}
189+
self._gateway_approval_requests: dict[str, tuple[str, str, str]] = {}
189190
self._gateway_approval_pattern_keys: dict[str, list[str]] = {}
190191
self._compression_requests: dict[str, queue.Queue[dict[str, Any]]] = {}
191192
self._background_notification_claims: dict[tuple[str, str], dict[str, Any]] = {}
@@ -1371,10 +1372,13 @@ def callback(command: str, description: str, *, allow_permanent: bool = True) ->
13711372
approval_id = uuid.uuid4().hex
13721373
response_queue: queue.Queue[str] = queue.Queue(maxsize=1)
13731374
with self._lock:
1375+
run_id = str(self._sessions.get(session_id).current_run_id or "") if self._sessions.get(session_id) else ""
13741376
self._approval_requests[approval_id] = response_queue
1377+
self._approval_request_generations[approval_id] = (session_id, run_id)
13751378
choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
13761379
self._append_event(session_id, {
13771380
"event": "approval.requested",
1381+
"run_id": run_id,
13781382
"approval_id": approval_id,
13791383
"command": str(command or ""),
13801384
"description": str(description or ""),
@@ -1389,8 +1393,10 @@ def callback(command: str, description: str, *, allow_permanent: bool = True) ->
13891393
finally:
13901394
with self._lock:
13911395
self._approval_requests.pop(approval_id, None)
1396+
self._approval_request_generations.pop(approval_id, None)
13921397
self._append_event(session_id, {
13931398
"event": "approval.resolved",
1399+
"run_id": run_id,
13941400
"approval_id": approval_id,
13951401
"choice": choice,
13961402
})
@@ -1467,11 +1473,14 @@ def callback(approval_data: dict[str, Any]) -> None:
14671473
approval_id = uuid.uuid4().hex
14681474
choices = ["once", "session", "always", "deny"]
14691475
pattern_keys = _approval_pattern_keys(approval_data)
1476+
request_id = str(approval_data.get("request_id") or "").strip()
14701477
with self._lock:
1471-
self._gateway_approval_requests[approval_id] = session_id
1478+
run_id = str(self._sessions.get(session_id).current_run_id or "") if self._sessions.get(session_id) else ""
1479+
self._gateway_approval_requests[approval_id] = (session_id, run_id, request_id)
14721480
self._gateway_approval_pattern_keys[approval_id] = pattern_keys
14731481
self._append_event(session_id, {
14741482
"event": "approval.requested",
1483+
"run_id": run_id,
14751484
"approval_id": approval_id,
14761485
"command": str(approval_data.get("command") or ""),
14771486
"description": str(approval_data.get("description") or ""),
@@ -2039,6 +2048,7 @@ def interrupt(self, session_id: str, message: str | None = None) -> dict[str, An
20392048
raise KeyError(f"unknown session: {session_id}")
20402049
with session.lock:
20412050
self._cancel_boundary_run(session)
2051+
interrupted_run_id = str(session.current_run_id or "")
20422052
background_delegation_ids = self._background_delegation_ids_for_session(session_id)
20432053
with self._lock:
20442054
self._suppressed_background_delegations.update(background_delegation_ids)
@@ -2050,6 +2060,7 @@ def interrupt(self, session_id: str, message: str | None = None) -> dict[str, An
20502060
if not hasattr(session.agent, "interrupt"):
20512061
raise RuntimeError("agent does not support interrupt")
20522062
session.agent.interrupt(message)
2063+
self._cancel_pending_approvals_for_generation(session_id, interrupted_run_id)
20532064
deadline = time.time() + 10.0
20542065
synced = False
20552066
while time.time() < deadline:
@@ -2066,6 +2077,50 @@ def interrupt(self, session_id: str, message: str | None = None) -> dict[str, An
20662077
"background_delegation_ids": background_delegation_ids,
20672078
}
20682079

2080+
def _cancel_pending_approvals_for_generation(self, session_id: str, run_id: str) -> int:
2081+
if not session_id or not run_id:
2082+
return 0
2083+
terminal_queues: list[queue.Queue[str]] = []
2084+
gateway_approvals: list[tuple[str, str, list[str]]] = []
2085+
with self._lock:
2086+
for approval_id, approval_generation in list(self._approval_request_generations.items()):
2087+
if approval_generation != (session_id, run_id):
2088+
continue
2089+
response_queue = self._approval_requests.pop(approval_id, None)
2090+
self._approval_request_generations.pop(approval_id, None)
2091+
if response_queue is not None:
2092+
terminal_queues.append(response_queue)
2093+
for approval_id, approval_generation in list(self._gateway_approval_requests.items()):
2094+
if approval_generation[:2] != (session_id, run_id):
2095+
continue
2096+
self._gateway_approval_requests.pop(approval_id, None)
2097+
gateway_approvals.append((
2098+
approval_id,
2099+
approval_generation[2],
2100+
self._gateway_approval_pattern_keys.pop(approval_id, []),
2101+
))
2102+
for response_queue in terminal_queues:
2103+
try:
2104+
response_queue.put_nowait("deny")
2105+
except queue.Full:
2106+
pass
2107+
for approval_id, request_id, _pattern_keys in gateway_approvals:
2108+
try:
2109+
from tools.approval import resolve_gateway_approval
2110+
2111+
if request_id:
2112+
resolve_gateway_approval(session_id, "deny", request_id=request_id)
2113+
except Exception:
2114+
pass
2115+
self._append_event(session_id, {
2116+
"event": "approval.resolved",
2117+
"run_id": run_id,
2118+
"approval_id": approval_id,
2119+
"choice": "deny",
2120+
"reason": "Session interrupted",
2121+
})
2122+
return len(terminal_queues) + len(gateway_approvals)
2123+
20692124
def request_boundary_interrupt(
20702125
self,
20712126
session_id: str,
@@ -2165,31 +2220,39 @@ def respond_approval(self, approval_id: str, choice: str) -> dict[str, Any]:
21652220
if cleaned not in {"once", "session", "always", "deny"}:
21662221
cleaned = "deny"
21672222
with self._lock:
2168-
response_queue = self._approval_requests.get(approval_id)
2223+
response_queue = self._approval_requests.pop(approval_id, None)
2224+
if response_queue is not None:
2225+
self._approval_request_generations.pop(approval_id, None)
2226+
try:
2227+
response_queue.put_nowait(cleaned)
2228+
except queue.Full:
2229+
pass
21692230
if response_queue is None:
21702231
with self._lock:
2171-
gateway_session_id = self._gateway_approval_requests.pop(approval_id, None)
2232+
gateway_generation = self._gateway_approval_requests.pop(approval_id, None)
21722233
pattern_keys = self._gateway_approval_pattern_keys.pop(approval_id, [])
2173-
if gateway_session_id is None:
2234+
if gateway_generation is None:
21742235
return {"approval_id": approval_id, "resolved": False, "choice": cleaned}
2236+
gateway_session_id, gateway_run_id, gateway_request_id = gateway_generation
21752237
try:
21762238
from tools.approval import resolve_gateway_approval
21772239

2178-
resolved = resolve_gateway_approval(gateway_session_id, cleaned) > 0
2240+
resolved = bool(gateway_request_id) and resolve_gateway_approval(
2241+
gateway_session_id,
2242+
cleaned,
2243+
request_id=gateway_request_id,
2244+
) > 0
21792245
except Exception:
21802246
resolved = False
21812247
if resolved:
21822248
_persist_execute_code_approval_choice(gateway_session_id, pattern_keys, cleaned)
21832249
self._append_event(gateway_session_id, {
21842250
"event": "approval.resolved",
2251+
"run_id": gateway_run_id,
21852252
"approval_id": approval_id,
21862253
"choice": cleaned,
21872254
})
21882255
return {"approval_id": approval_id, "resolved": resolved, "choice": cleaned}
2189-
try:
2190-
response_queue.put_nowait(cleaned)
2191-
except queue.Full:
2192-
pass
21932256
return {"approval_id": approval_id, "resolved": True, "choice": cleaned}
21942257

21952258
def respond_clarify(self, clarify_id: str, response: str) -> dict[str, Any]:

packages/server/src/services/hermes/group-chat/agent-clients.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,7 +1158,12 @@ export class AgentClient implements GroupAgentExecutor {
11581158
} else if (event === 'tool.completed' || event === 'tool.failed') {
11591159
queueToolEventWrite(() => this.recordToolCompleted(roomId, sessionId, { ...payload, event }).then(() => undefined))
11601160
} else if (event === 'approval.requested') {
1161-
this.emitApprovalRequested(roomId, { ...payload, agentSessionId: sessionId })
1161+
const { run_id: _runtimeRunId, runId: _runtimeCamelRunId, ...approvalPayload } = payload
1162+
this.emitApprovalRequested(roomId, {
1163+
...approvalPayload,
1164+
agentSessionId: sessionId,
1165+
runId: responseRunId,
1166+
})
11621167
} else if (event === 'approval.resolved') {
11631168
this.emitApprovalResolved(roomId, { ...payload, agentSessionId: sessionId })
11641169
} else if (event === 'clarify.requested') {
@@ -1569,6 +1574,7 @@ export class AgentClient implements GroupAgentExecutor {
15691574
this.emitApprovalRequested(roomId, {
15701575
event: 'approval.requested',
15711576
agentSessionId: sessionId,
1577+
runId: responseRunId,
15721578
approval_id: (ev as any).approval_id,
15731579
command: (ev as any).command,
15741580
description: (ev as any).description,

packages/server/src/services/hermes/group-chat/index.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ interface PendingGroupApprovalRoute {
9494
agentName: string
9595
ownerMemberId: string
9696
agentSessionId: string
97+
runId: string
9798
approvalId: string
9899
command: string
99100
description: string
@@ -3917,7 +3918,7 @@ export class GroupChatServer {
39173918
socket.on('cancel_execution_queue_item', (data: { roomId?: string; queueId?: string; executionQueueCapability?: string }, ack?: (response?: unknown) => void) => this.handleCancelExecutionQueueItem(socket, data, ack))
39183919
socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack))
39193920
socket.on('remove_agent', (data: { roomId?: string; agentId?: string }, ack?: (response?: unknown) => void) => this.handleRemoveAgent(socket, data, ack))
3920-
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean; timeout_ms?: number; agentSessionId?: string }) => this.handleApprovalRequested(socket, data))
3921+
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean; timeout_ms?: number; agentSessionId?: string; runId?: string }) => this.handleApprovalRequested(socket, data))
39213922
socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string; agentSessionId?: string }) => this.handleApprovalResolved(socket, data))
39223923
socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack))
39233924
socket.on('clarify.requested', (data: { roomId?: string; agentName?: string; clarify_id?: string; question?: string; choices?: string[] | null; initial_response?: string; response_mode?: string; timeout_ms?: number; agentSessionId?: string; runId?: string; runtimeRunId?: string }) => this.handleClarifyRequested(socket, data))
@@ -4876,6 +4877,12 @@ export class GroupChatServer {
48764877
return
48774878
}
48784879
const activeGeneration = this.contextStatusState.get(roomId)?.get(agentName)
4880+
const interruptedApprovals = this.takePendingApprovalsForGeneration(
4881+
roomId,
4882+
agentName,
4883+
activeGeneration?.agentSessionId || '',
4884+
activeGeneration?.runId || '',
4885+
)
48794886
const interruptedClarifications = this.takePendingEkkoClarificationsForGeneration(
48804887
roomId,
48814888
agentName,
@@ -4884,7 +4891,10 @@ export class GroupChatServer {
48844891
)
48854892
try {
48864893
await this.agentClients.interruptAgent(roomId, agentName)
4887-
await this.settleInterruptedEkkoClarifications(interruptedClarifications)
4894+
await Promise.all([
4895+
this.settleInterruptedApprovals(interruptedApprovals),
4896+
this.settleInterruptedEkkoClarifications(interruptedClarifications),
4897+
])
48884898
const roomStatuses = this.contextStatusState.get(roomId)
48894899
roomStatuses?.delete(agentName)
48904900
if (roomStatuses?.size === 0) this.contextStatusState.delete(roomId)
@@ -4895,7 +4905,10 @@ export class GroupChatServer {
48954905
this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' })
48964906
ack?.({ ok: true })
48974907
} catch (err: any) {
4898-
await this.settleInterruptedEkkoClarifications(interruptedClarifications)
4908+
await Promise.all([
4909+
this.settleInterruptedApprovals(interruptedApprovals),
4910+
this.settleInterruptedEkkoClarifications(interruptedClarifications),
4911+
])
48994912
logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`)
49004913
ack?.({ error: err.message || 'interrupt failed' })
49014914
}
@@ -4996,7 +5009,7 @@ export class GroupChatServer {
49965009
})
49975010
}
49985011

4999-
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean; timeout_ms?: number; agentSessionId?: string }): void {
5012+
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean; timeout_ms?: number; agentSessionId?: string; runId?: string }): void {
50005013
const roomId = data.roomId
50015014
const agentName = data.agentName || ''
50025015
if (!roomId || !data.approval_id || !this.getCurrentAgentEventMember(socket, roomId, agentName, data.agentSessionId)) return
@@ -5008,6 +5021,7 @@ export class GroupChatServer {
50085021
agentName,
50095022
ownerMemberId: this.groupAgentOwnerMemberId(roomId, agentName),
50105023
agentSessionId: String(data.agentSessionId || '').trim(),
5024+
runId: String(data.runId || '').trim(),
50115025
approvalId: data.approval_id,
50125026
command: data.command || '',
50135027
description: data.description || '',
@@ -5034,6 +5048,56 @@ export class GroupChatServer {
50345048
})
50355049
}
50365050

5051+
private takePendingApprovalsForGeneration(
5052+
roomId: string,
5053+
agentName: string,
5054+
agentSessionId: string,
5055+
runId: string,
5056+
): PendingGroupApprovalRoute[] {
5057+
if (!agentSessionId || !runId || !(this.pendingApprovalRoutes instanceof Map)) return []
5058+
const routes: PendingGroupApprovalRoute[] = []
5059+
for (const [routeKey, route] of this.pendingApprovalRoutes) {
5060+
if (route.roomId !== roomId
5061+
|| route.agentName !== agentName
5062+
|| route.agentSessionId !== agentSessionId
5063+
|| !route.runId
5064+
|| route.runId !== runId) {
5065+
continue
5066+
}
5067+
const claimed = this.takePendingApprovalRoute(routeKey)
5068+
if (claimed) routes.push(claimed)
5069+
}
5070+
return routes
5071+
}
5072+
5073+
private async settleInterruptedApprovals(routes: PendingGroupApprovalRoute[]): Promise<void> {
5074+
for (const route of routes) {
5075+
let resolved = false
5076+
const executor = this.agentClients.getAgents(route.roomId).find(agent =>
5077+
agent.name === route.agentName && typeof agent.respondApproval === 'function'
5078+
)
5079+
try {
5080+
resolved = Boolean(await executor?.respondApproval?.(route.approvalId, 'deny'))
5081+
if (!resolved) {
5082+
resolved = Boolean((await new AgentBridgeClient().approvalRespond(route.approvalId, 'deny') as any)?.resolved)
5083+
}
5084+
} catch (err: any) {
5085+
if (!isExpiredInteractionError(err?.message || err)) {
5086+
logger.warn(`[GroupChat] failed to cancel interrupted approval ${route.approvalId}: ${err?.message || err}`)
5087+
}
5088+
}
5089+
this.emitToAgentApprovalOwner(route, 'approval.resolved', {
5090+
event: 'approval.resolved',
5091+
roomId: route.roomId,
5092+
agentName: route.agentName,
5093+
approval_id: route.approvalId,
5094+
choice: 'deny',
5095+
reason: 'Agent run interrupted',
5096+
resolved,
5097+
})
5098+
}
5099+
}
5100+
50375101
private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string; agentSessionId?: string }): void {
50385102
const roomId = data.roomId
50395103
const agentName = data.agentName || ''

0 commit comments

Comments
 (0)