fix tool approval flow (#773)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -152,6 +152,12 @@ export default {
|
||||
historyScopeHint: 'Sessions d’historique 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',
|
||||
|
||||
@@ -152,6 +152,12 @@ export default {
|
||||
historyScopeHint: 'ソース別にグループ化された Hermes 履歴セッションを読み取り専用で表示します。',
|
||||
noSessions: 'セッションがありません',
|
||||
newChat: '新しいチャット',
|
||||
approvalKicker: 'ターミナル権限',
|
||||
approvalTitle: '実行前にコマンドを確認',
|
||||
approvalAllowOnce: '一度だけ許可',
|
||||
approvalAllowSession: 'セッション中は許可',
|
||||
approvalAlways: '常に許可',
|
||||
approvalDeny: '拒否',
|
||||
deleteSession: 'このセッションを削除しますか?',
|
||||
toggleBatchMode: '一括選択',
|
||||
selectAll: 'すべて選択',
|
||||
|
||||
@@ -152,6 +152,12 @@ export default {
|
||||
historyScopeHint: '소스별로 그룹화된 Hermes 기록 세션을 읽기 전용으로 봅니다.',
|
||||
noSessions: '세션 없음',
|
||||
newChat: '새 채팅',
|
||||
approvalKicker: '터미널 권한',
|
||||
approvalTitle: '실행 전에 명령 확인',
|
||||
approvalAllowOnce: '한 번만 허용',
|
||||
approvalAllowSession: '이 세션에서 허용',
|
||||
approvalAlways: '항상 허용',
|
||||
approvalDeny: '거부',
|
||||
deleteSession: '이 세션을 삭제하시겠습니까?',
|
||||
toggleBatchMode: '일괄 선택',
|
||||
selectAll: '모두 선택',
|
||||
|
||||
@@ -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: '批次選取',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user