fix tool approval flow (#773)

This commit is contained in:
ekko
2026-05-16 00:11:51 +08:00
committed by GitHub
parent 015c698993
commit 8bb71b5592
11 changed files with 235 additions and 53 deletions
@@ -210,6 +210,7 @@ const activeSessionSource = computed(() =>
);
const activeApproval = computed(() => chatStore.activePendingApproval);
const visibleApproval = computed(() => activeApproval.value);
function handleNewChat() {
chatStore.newChat();
@@ -835,44 +836,64 @@ async function handleWorkspaceConfirm() {
<template v-if="currentMode === 'chat'">
<MessageList />
<div v-if="activeApproval" class="approval-bar">
<div class="approval-main">
<div class="approval-title">Tool approval required</div>
<div class="approval-desc">{{ activeApproval.description }}</div>
<code class="approval-command">{{ activeApproval.command }}</code>
<div v-if="visibleApproval" class="approval-bar">
<div class="approval-icon" aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div class="approval-actions">
<NButton
v-if="activeApproval.choices.includes('once')"
size="small"
type="primary"
@click="handleApproval('once')"
>
Allow once
</NButton>
<NButton
v-if="activeApproval.choices.includes('session')"
size="small"
@click="handleApproval('session')"
>
Allow session
</NButton>
<NButton
v-if="activeApproval.choices.includes('always')"
size="small"
@click="handleApproval('always')"
>
Always
</NButton>
<NButton
v-if="activeApproval.choices.includes('deny')"
size="small"
type="error"
ghost
@click="handleApproval('deny')"
>
Deny
</NButton>
<div class="approval-content">
<div class="approval-main">
<div class="approval-kicker">{{ t("chat.approvalKicker") }}</div>
<div class="approval-title">{{ t("chat.approvalTitle") }}</div>
<div class="approval-desc">{{ visibleApproval.description }}</div>
<code class="approval-command">{{ visibleApproval.command }}</code>
</div>
<div class="approval-actions">
<NButton
v-if="visibleApproval.choices.includes('once')"
size="small"
type="primary"
@click="handleApproval('once')"
>
{{ t("chat.approvalAllowOnce") }}
</NButton>
<NButton
v-if="visibleApproval.choices.includes('session')"
size="small"
secondary
@click="handleApproval('session')"
>
{{ t("chat.approvalAllowSession") }}
</NButton>
<NButton
v-if="visibleApproval.choices.includes('always')"
size="small"
secondary
@click="handleApproval('always')"
>
{{ t("chat.approvalAlways") }}
</NButton>
<NButton
v-if="visibleApproval.choices.includes('deny')"
size="small"
type="error"
secondary
@click="handleApproval('deny')"
>
{{ t("chat.approvalDeny") }}
</NButton>
</div>
</div>
</div>
<ChatInput />
@@ -1348,50 +1369,118 @@ async function handleWorkspaceConfirm() {
.approval-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid $border-color;
align-items: flex-start;
gap: 10px;
margin: 0 16px 12px;
padding: 12px;
border: 1px solid $border-color;
border-radius: 8px;
background: $bg-card;
box-shadow: none;
}
.approval-main {
.approval-icon {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.12);
border: 1px solid rgba(var(--accent-primary-rgb), 0.2);
border-radius: 8px;
}
.approval-content {
flex: 1;
min-width: 0;
}
.approval-main {
min-width: 0;
}
.approval-kicker {
margin-bottom: 2px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-primary);
}
.approval-title {
font-size: 13px;
font-weight: 600;
font-size: 14px;
font-weight: 700;
line-height: 1.3;
color: $text-primary;
}
.approval-desc {
margin-top: 2px;
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: $text-secondary;
}
.approval-command {
display: block;
margin-top: 6px;
max-height: 56px;
margin-top: 8px;
max-height: 96px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
font-size: 11px;
line-height: 1.45;
color: $text-primary;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: 6px;
padding: 6px 8px;
padding: 8px 10px;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid $border-color;
}
@media (max-width: 768px) {
.approval-bar {
margin: 0 10px 10px;
padding: 10px;
}
.approval-icon {
flex-basis: 28px;
width: 28px;
height: 28px;
}
.approval-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.approval-actions :deep(.n-button) {
width: 100%;
}
}
@media (max-width: 420px) {
.approval-bar {
gap: 8px;
}
.approval-actions {
grid-template-columns: 1fr;
}
}
@keyframes rainbow-glow {
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: 'Schreibgeschützte Hermes-Verlaufssitzungen, nach Quelle gruppiert.',
noSessions: 'Keine Sitzungen',
newChat: 'Neuer Chat',
approvalKicker: 'Terminal-Berechtigung',
approvalTitle: 'Befehl vor dem Ausführen prüfen',
approvalAllowOnce: 'Einmal erlauben',
approvalAllowSession: 'Sitzung erlauben',
approvalAlways: 'Immer',
approvalDeny: 'Ablehnen',
deleteSession: 'Diese Sitzung loschen?',
toggleBatchMode: 'Batch-Auswahl',
selectAll: 'Alle auswählen',
+6
View File
@@ -178,6 +178,12 @@ export default {
searchEnterHint: 'Enter to open · Esc to close',
searchFailed: 'Failed to search sessions',
newChat: 'New Chat',
approvalKicker: 'Terminal permission',
approvalTitle: 'Review command before running',
approvalAllowOnce: 'Allow once',
approvalAllowSession: 'Allow session',
approvalAlways: 'Always',
approvalDeny: 'Deny',
newCliChat: 'New CLI',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: 'Sesiones del historial de Hermes, de solo lectura y agrupadas por origen.',
noSessions: 'Sin sesiones',
newChat: 'Nuevo chat',
approvalKicker: 'Permiso de terminal',
approvalTitle: 'Revisar comando antes de ejecutar',
approvalAllowOnce: 'Permitir una vez',
approvalAllowSession: 'Permitir sesión',
approvalAlways: 'Siempre',
approvalDeny: 'Denegar',
deleteSession: 'Eliminar esta sesion?',
toggleBatchMode: 'Selección por lotes',
selectAll: 'Seleccionar todo',
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: 'Sessions dhistorique Hermes en lecture seule, regroupées par source.',
noSessions: 'Aucune session',
newChat: 'Nouvelle discussion',
approvalKicker: 'Permission terminal',
approvalTitle: 'Vérifier la commande avant exécution',
approvalAllowOnce: 'Autoriser une fois',
approvalAllowSession: 'Autoriser la session',
approvalAlways: 'Toujours',
approvalDeny: 'Refuser',
deleteSession: 'Supprimer cette session ?',
toggleBatchMode: 'Sélection par lot',
selectAll: 'Tout sélectionner',
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: 'ソース別にグループ化された Hermes 履歴セッションを読み取り専用で表示します。',
noSessions: 'セッションがありません',
newChat: '新しいチャット',
approvalKicker: 'ターミナル権限',
approvalTitle: '実行前にコマンドを確認',
approvalAllowOnce: '一度だけ許可',
approvalAllowSession: 'セッション中は許可',
approvalAlways: '常に許可',
approvalDeny: '拒否',
deleteSession: 'このセッションを削除しますか?',
toggleBatchMode: '一括選択',
selectAll: 'すべて選択',
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: '소스별로 그룹화된 Hermes 기록 세션을 읽기 전용으로 봅니다.',
noSessions: '세션 없음',
newChat: '새 채팅',
approvalKicker: '터미널 권한',
approvalTitle: '실행 전에 명령 확인',
approvalAllowOnce: '한 번만 허용',
approvalAllowSession: '이 세션에서 허용',
approvalAlways: '항상 허용',
approvalDeny: '거부',
deleteSession: '이 세션을 삭제하시겠습니까?',
toggleBatchMode: '일괄 선택',
selectAll: '모두 선택',
+6
View File
@@ -152,6 +152,12 @@ export default {
historyScopeHint: 'Sessões do histórico Hermes somente leitura, agrupadas por origem.',
noSessions: 'Sem sessoes',
newChat: 'Novo chat',
approvalKicker: 'Permissão do terminal',
approvalTitle: 'Revisar comando antes de executar',
approvalAllowOnce: 'Permitir uma vez',
approvalAllowSession: 'Permitir sessão',
approvalAlways: 'Sempre',
approvalDeny: 'Negar',
deleteSession: 'Excluir esta sessao?',
toggleBatchMode: 'Seleção em lote',
selectAll: 'Selecionar tudo',
@@ -177,6 +177,12 @@ export default {
searchEnterHint: 'Enter 開啟 · Esc 關閉',
searchFailed: '搜尋工作階段失敗',
newChat: '新增對話',
approvalKicker: '終端授權',
approvalTitle: '執行前請確認命令',
approvalAllowOnce: '僅本次允許',
approvalAllowSession: '本工作階段允許',
approvalAlways: '永遠允許',
approvalDeny: '拒絕',
deleteSession: '確定刪除此工作階段?',
sessionDeleted: '工作階段已刪除',
toggleBatchMode: '批次選取',
+6
View File
@@ -178,6 +178,12 @@ export default {
searchEnterHint: 'Enter 打开 · Esc 关闭',
searchFailed: '搜索会话失败',
newChat: '新建对话',
approvalKicker: '终端授权',
approvalTitle: '运行前请确认命令',
approvalAllowOnce: '仅本次允许',
approvalAllowSession: '本会话允许',
approvalAlways: '始终允许',
approvalDeny: '拒绝',
newCliChat: '新建 CLI',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
@@ -356,6 +356,7 @@ class AgentPool:
self._run_lock = threading.Lock()
self._db = SessionDbHolder()
self._approval_requests: dict[str, queue.Queue[str]] = {}
self._gateway_approval_requests: dict[str, str] = {}
self._compression_requests: dict[str, queue.Queue[dict[str, Any]]] = {}
def get_or_create(
@@ -673,6 +674,24 @@ class AgentPool:
return callback
def _gateway_approval_notify(self, session_id: str):
def callback(approval_data: dict[str, Any]) -> None:
approval_id = uuid.uuid4().hex
choices = ["once", "session", "always", "deny"]
with self._lock:
self._gateway_approval_requests[approval_id] = session_id
self._append_event(session_id, {
"event": "approval.requested",
"approval_id": approval_id,
"command": str(approval_data.get("command") or ""),
"description": str(approval_data.get("description") or ""),
"choices": choices,
"allow_permanent": True,
"timeout_ms": 300_000,
})
return callback
def _prepersist_user_message(
self,
session: AgentSession,
@@ -833,13 +852,16 @@ class AgentPool:
previous_approval_callback = None
previous_exec_ask = os.environ.get("HERMES_EXEC_ASK")
approval_session_token = None
registered_gateway_approval_session = None
try:
from tools.terminal_tool import _get_approval_callback, set_approval_callback
from tools.approval import set_current_session_key
from tools.approval import register_gateway_notify, set_current_session_key
previous_approval_callback = _get_approval_callback()
set_approval_callback(self._approval_callback(session.session_id))
approval_session_token = set_current_session_key(session.session_id)
register_gateway_notify(session.session_id, self._gateway_approval_notify(session.session_id))
registered_gateway_approval_session = session.session_id
os.environ["HERMES_EXEC_ASK"] = "1"
except Exception:
previous_approval_callback = None
@@ -905,8 +927,10 @@ class AgentPool:
pass
if approval_session_token is not None:
try:
from tools.approval import reset_current_session_key
from tools.approval import reset_current_session_key, unregister_gateway_notify
if registered_gateway_approval_session is not None:
unregister_gateway_notify(registered_gateway_approval_session)
reset_current_session_key(approval_session_token)
except Exception:
pass
@@ -950,7 +974,22 @@ class AgentPool:
with self._lock:
response_queue = self._approval_requests.get(approval_id)
if response_queue is None:
return {"approval_id": approval_id, "resolved": False, "choice": cleaned}
with self._lock:
gateway_session_id = self._gateway_approval_requests.pop(approval_id, None)
if gateway_session_id is None:
return {"approval_id": approval_id, "resolved": False, "choice": cleaned}
try:
from tools.approval import resolve_gateway_approval
resolved = resolve_gateway_approval(gateway_session_id, cleaned) > 0
except Exception:
resolved = False
self._append_event(gateway_session_id, {
"event": "approval.resolved",
"approval_id": approval_id,
"choice": cleaned,
})
return {"approval_id": approval_id, "resolved": resolved, "choice": cleaned}
try:
response_queue.put_nowait(cleaned)
except queue.Full: