Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agent/managed_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ def read(self):
self.finish.set()
return
if (set(frame) != {'type', 'prompt_id', 'value'} or frame['type'] not in {'approval', 'clarify'}
or not isinstance(frame['prompt_id'], str) or not isinstance(frame['value'], str)
or not isinstance(frame['prompt_id'], str) or not frame['prompt_id']
or not isinstance(frame['value'], str)
or len(frame['value']) > 16384):
raise ValueError('invalid_managed_control')
if frame['type'] == 'approval':
Expand Down
6 changes: 4 additions & 2 deletions gateway/relay/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1940,7 +1940,7 @@ async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendRe
metadata = dict(prompt.metadata or {})
metadata.pop("_approval_request_id", None)
result = await self._mint_and_send_prompt(
"exec_approval", {"session_key": prompt.session_key}, prompt.chat_id, prompt_kind="approval",
"exec_approval", {"session_key": prompt.session_key, "request_id": prompt.request_id}, prompt.chat_id, prompt_kind="approval",
text=prompt.text, options=options, metadata=metadata,
)
return result if result is not None else self._PROMPT_UNAVAILABLE
Expand Down Expand Up @@ -2055,7 +2055,9 @@ async def _resolve_exec_approval(self, state, option_id, chat_id, ack_meta) -> N
from tools.approval import resolve_gateway_approval

choice = option_id if option_id in _EXEC_APPROVAL_LABELS else "deny"
count = resolve_gateway_approval(str(state.get("session_key") or ""), choice)
request_id = state.get("request_id")
count = resolve_gateway_approval(str(state.get("session_key") or ""), choice,
request_id=request_id) if request_id else 0
label = _EXEC_APPROVAL_LABELS[choice] if count else "⌛ Approval expired — no command was waiting."
# In-channel ack preserves the audit trail the native edit gives (the
# connector's prompt message can't be edited cross-platform yet).
Expand Down
2 changes: 1 addition & 1 deletion gateway/session_pending_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def register(self, session_id, route, generation, data, *, timeout_s=None):
'execution_generation': generation,
'command': _redact_approval_command(data.get('command', '')),
'description': _redact_approval_command(data.get('description', '')),
'choices': choices}
'choices': choices, 'smart_denied': bool(data.get('smart_denied'))}
if 'edit' in data:
prompt['edit'] = deepcopy(data['edit'])
prompt['choices'] = ['once', 'deny']
Expand Down
6 changes: 5 additions & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,7 @@ def _blocking_approval_or_stale(self, event: MessageEvent, stale_key: str, none_
async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
"""Handle /approve — unblock waiting agent thread(s). They block inside tools/approval.py;
signalling the event resumes them so the command executes inline (same flow as the CLI)."""
from tools.approval import resolve_gateway_approval
from tools.approval import list_gateway_approvals, resolve_gateway_approval
session_key, stale = self._blocking_approval_or_stale(event, "gateway.approval_expired",
"gateway.approve.no_pending")
if stale:
Expand All @@ -1205,8 +1205,12 @@ async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
choice = "always" if "always" in choices else "session" if "session" in choices else "once"
count = resolve_gateway_approval(session_key, choice, resolve_all="all" in args)
if not count:
if any(entry.get("smart_denied") for entry in list_gateway_approvals(session_key)):
return t("gateway.approve.own_card")
return t("gateway.approve.no_pending")
confirmation_text = t(f"gateway.approve.{choice}_{'plural' if count > 1 else 'singular'}", count=count)
if "all" in args and any(entry.get("smart_denied") for entry in list_gateway_approvals(session_key)):
confirmation_text += "\n" + t("gateway.approve.own_card")
logger.info("User approved %d dangerous command(s) via /approve (%s)", count, choice)
return await self._deliver_approval_confirmation(event, confirmation_text, "approve")

Expand Down
1 change: 1 addition & 0 deletions hermes_cli/cli_modal_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,7 @@ def _approval_callback(self, command: str, description: str,
allow_session=allow_session,
smart_denied=smart_denied),
"selected": 0,
"smart_denied": smart_denied,
"response_queue": response_queue}
self._approval_deadline = _time.monotonic() + timeout
self._ring_bell(prompt=True, context=t("cli.approval.bell_context"), detail=command)
Expand Down
66 changes: 43 additions & 23 deletions hermes_cli/cli_tui_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,23 @@ def _get_approval_display_fragments(self):
and is truncated to the remaining row budget, so HSplit never clips approve/deny off-screen.
"""
from cli import _panel_box_width, _wrap_panel_text_keep_ws
from tools.approval import REVIEWER_DENY_NOTE
state = self._approval_state
if not state:
return []
wrap = _wrap_panel_text_keep_ws
command = state["command"]
description = state["description"]
reviewer_warning = None
if state.get("smart_denied"):
description_lines = description.splitlines()
warning_index = next((
i for i, line in enumerate(description_lines)
if line.startswith(REVIEWER_DENY_NOTE)
), None)
if warning_index is not None:
reviewer_warning = description_lines.pop(warning_index)
description = "\n".join(description_lines)
choices = state["choices"]
selected = state.get("selected", 0)
show_full = state.get("show_full", False)
Expand All @@ -213,7 +224,7 @@ def _get_approval_display_fragments(self):
box_width = _panel_box_width(title, preview_lines)
inner_text_width = max(8, box_width - 2)

# Pre-wrap the mandatory content — command + choices must always render.
# Wrap content before allocating the available rows.
cmd_wrapped = wrap(command, inner_text_width)
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
cmd_wrapped = cmd_wrapped[:3] + wrap(
Expand All @@ -222,37 +233,46 @@ def _get_approval_display_fragments(self):
f"{'❯' if i == selected else ' '} {_num_prefix(i)}. {_approval_choice_label(choice)}"
for i, choice in enumerate(choices)]
choice_wrapped = _wrap_rows(wrap, choice_labels, inner_text_width, " ")
warning_wrapped = wrap(reviewer_warning, inner_text_width) if reviewer_warning else []

# Row budget so HSplit never clips the command or choices. Full chrome = top border +
# title + blank + blank-between-cmd/choices + bottom border (5); when that doesn't fit,
# drop the separator blanks (3) so every choice stays on-screen in compact terminals.
available = max(0, _term_rows() - _PANEL_RESERVED_BELOW)
use_compact_chrome = 5 + len(cmd_wrapped) + len(choice_wrapped) > available
chrome_rows = 3 if use_compact_chrome else 5

# A command too long to leave room for the choices (e.g. "view" on a multi-hundred-char
# command) is truncated so approve/deny still render; keep at least 1 command row.
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
# Borders take two rows; full chrome adds a title and two separators.
# Compact warning panels place the title in the border.
use_compact_chrome = 5 + len(cmd_wrapped) + len(choice_wrapped) + len(warning_wrapped) > available
inline_title = use_compact_chrome and bool(warning_wrapped)
omit_bottom_border = bool(warning_wrapped) and 2 + len(choice_wrapped) + 2 > available
chrome_rows = 2 - int(omit_bottom_border) + (0 if inline_title else 1) + (0 if use_compact_chrome else 2)
warning_budget = max(1, available - chrome_rows - len(choice_wrapped) - 1)
if len(warning_wrapped) > warning_budget:
warning_wrapped = warning_wrapped[:warning_budget]
if warning_wrapped:
warning_wrapped[-1] = warning_wrapped[-1][:max(0, inner_text_width - 2)] + " …"
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(warning_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
cmd_wrapped = cmd_wrapped[:keep] + wrap(
t("cli.tui.approval_command_truncated"), inner_text_width)
if max_cmd_rows == 1:
cmd_wrapped = [cmd_wrapped[0]]
else:
cmd_wrapped = cmd_wrapped[:max_cmd_rows - 1] + [
t("cli.tui.approval_command_truncated")]

# Remaining rows go to the description (minus the blank separator in full mode), capped
# at 10 so the panel stays compact even on huge terminals.
mandatory_no_desc = chrome_rows + len(cmd_wrapped) + len(choice_wrapped)
# Remaining rows go to the description (minus its blank separator), capped at 10.
mandatory_no_desc = chrome_rows + len(warning_wrapped) + len(cmd_wrapped) + len(choice_wrapped)
available_for_desc = available - mandatory_no_desc - (0 if use_compact_chrome else 1)
available_for_desc = max(0, min(available_for_desc, 10))
desc_wrapped = wrap(description, inner_text_width) if description else []
if available_for_desc < 1 or not desc_wrapped:
desc_wrapped = []
elif len(desc_wrapped) > available_for_desc:
desc_wrapped = desc_wrapped[:max(1, available_for_desc - 1)] + [t("cli.tui.approval_description_truncated")]

# Render title → command → choices → description; description last so any overflow
# clips the least-critical content, never the command or choices.
panel = _Panel('class:approval-border', box_width)
panel.row('class:approval-title', title)
desc_wrapped = desc_wrapped[:available_for_desc - 1] + [
t("cli.tui.approval_description_truncated")]
# Render the warning before the command; compact chrome integrates the title into its border.
panel = _Panel('class:approval-border', box_width,
title if inline_title else "",
'class:approval-title')
if not inline_title:
panel.row('class:approval-title', title)
for wrapped in warning_wrapped:
panel.row('class:approval-desc', wrapped)
if not use_compact_chrome:
panel.blank()
for wrapped in cmd_wrapped:
Expand All @@ -267,7 +287,7 @@ def _get_approval_display_fragments(self):
panel.blank()
for wrapped in desc_wrapped:
panel.row('class:approval-desc', wrapped)
return panel.close()
return panel.lines if omit_bottom_border else panel.close()

def _get_tui_prompt_symbols(self) -> tuple[str, str]:
"""Return ``(normal_prompt, state_suffix)`` for the active skin.
Expand Down
7 changes: 4 additions & 3 deletions hermes_cli/gateway_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,16 +988,17 @@ def _emit_print(self, text):
(self._print_fn or print)(text)

def _on_approval(self, payload, stream_callback, starts):
from tools.approval_prompt import callback_accepts
from tools.terminal_tool import _get_approval_callback
callback = _get_approval_callback()
choices = list(payload.get("choices") or [])
choice = "deny"
if callback is not None:
try:
if callback_accepts(callback, "smart_denied"):
choice = callback(payload.get("command") or "", payload.get("description") or "",
allow_permanent="always" in choices, allow_session="session" in choices,
smart_denied=False)
except TypeError:
smart_denied=bool(payload.get("smart_denied")))
else:
choice = callback(payload.get("command") or "", payload.get("description") or "")
if choice not in choices:
choice = "deny"
Expand Down
1 change: 1 addition & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - kind {index}: {calls} API-oproepe · {doing}"
active_ago: " · {seconds}s gelede aktief"
approve:
own_card: "Versoeke wat die beoordelaar afgekeur het, moet op hul eie kaart beantwoord word."
no_pending: "Geen hangende opdrag om goed te keur nie."
once_singular: "✅ Opdrag goedgekeur. Die agent gaan voort..."
once_plural: "✅ Opdragte goedgekeur ({count} opdragte). Die agent gaan voort..."
Expand Down
1 change: 1 addition & 0 deletions locales/ar.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - الوكيل الفرعي {index}: {calls} استدعاء API · {doing}"
active_ago: " · كان نشطًا قبل {seconds} ث"
approve:
own_card: "يجب الرد على الطلبات التي رفضها المراجع في بطاقتها الخاصة."
no_pending: "لا يوجد أمر معلّق للموافقة عليه."
once_singular: "✅ تمت الموافقة على الأمر. الوكيل يستأنف..."
once_plural: "✅ تمت الموافقة على الأوامر ({count} أوامر). الوكيل يستأنف..."
Expand Down
1 change: 1 addition & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - Kind {index}: {calls} API-Aufrufe · {doing}"
active_ago: " · vor {seconds}s aktiv"
approve:
own_card: "Vom Prüfer abgelehnte Anfragen müssen auf ihrer eigenen Karte beantwortet werden."
no_pending: "Kein ausstehender Befehl zum Genehmigen."
once_singular: "✅ Befehl genehmigt. Der Agent wird fortgesetzt..."
once_plural: "✅ Befehle genehmigt ({count} Befehle). Der Agent wird fortgesetzt..."
Expand Down
1 change: 1 addition & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ gateway:
child_row: " - child {index}: {calls} api calls · {doing}"
active_ago: " · active {seconds}s ago"
approve:
own_card: "Reviewer DENY requests must be answered on their own card."
no_pending: "No pending command to approve."
once_singular: "✅ Command approved. The agent is resuming..."
once_plural: "✅ Commands approved ({count} commands). The agent is resuming..."
Expand Down
1 change: 1 addition & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - hijo {index}: {calls} llamadas a la API · {doing}"
active_ago: " · activo hace {seconds}s"
approve:
own_card: "Las solicitudes rechazadas por el revisor deben responderse en su propia tarjeta."
no_pending: "No hay ningún comando pendiente que aprobar."
once_singular: "✅ Comando aprobado. El agente se está reanudando..."
once_plural: "✅ Comandos aprobados ({count} comandos). El agente se está reanudando..."
Expand Down
1 change: 1 addition & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ gateway:
child_row: " - enfant {index} : {calls} appels API · {doing}"
active_ago: " · actif il y a {seconds}s"
approve:
own_card: "Les demandes refusées par le réviseur doivent être traitées sur leur propre carte."
no_pending: "Aucune commande en attente d'approbation."
once_singular: "✅ Commande approuvée. L'agent reprend..."
once_plural: "✅ Commandes approuvées ({count} commandes). L'agent reprend..."
Expand Down
1 change: 1 addition & 0 deletions locales/ga.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - fo-ghníomhaire {index}: {calls} glao API · {doing}"
active_ago: " · gníomhach {seconds}s ó shin"
approve:
own_card: "Ní mór iarratais a dhiúltaigh an t-athbhreithneoir a fhreagairt ar a gcárta féin."
no_pending: "Níl aon ordú ag fanacht le ceadú."
once_singular: "✅ Ordú ceadaithe. Tá an gníomhaire ag atosú..."
once_plural: "✅ Orduithe ceadaithe ({count} ordú). Tá an gníomhaire ag atosú..."
Expand Down
1 change: 1 addition & 0 deletions locales/hu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - gyerek {index}: {calls} API-hívás · {doing}"
active_ago: " · {seconds} mp-e volt aktív"
approve:
own_card: "Az ellenőrző által elutasított kéréseket a saját kártyájukon kell megválaszolni."
no_pending: "Nincs jóváhagyásra váró parancs."
once_singular: "✅ Parancs jóváhagyva. Az ügynök folytatja..."
once_plural: "✅ Parancsok jóváhagyva ({count} parancs). Az ügynök folytatja..."
Expand Down
1 change: 1 addition & 0 deletions locales/it.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - figlio {index}: {calls} chiamate API · {doing}"
active_ago: " · attivo {seconds}s fa"
approve:
own_card: "Le richieste rifiutate dal revisore devono essere gestite sulla propria scheda."
no_pending: "Nessun comando in attesa di approvazione."
once_singular: "✅ Comando approvato. L'agente sta riprendendo..."
once_plural: "✅ Comandi approvati ({count} comandi). L'agente sta riprendendo..."
Expand Down
1 change: 1 addition & 0 deletions locales/ja.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - 子 {index}: API 呼び出し {calls} 回 · {doing}"
active_ago: " · {seconds} 秒前に活動"
approve:
own_card: "レビュー担当が拒否したリクエストには、それぞれのカードで回答してください。"
no_pending: "承認待ちのコマンドはありません。"
once_singular: "✅ コマンドを承認しました。エージェントを再開しています..."
once_plural: "✅ コマンドを承認しました ({count} 件)。エージェントを再開しています..."
Expand Down
1 change: 1 addition & 0 deletions locales/ko.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - 자식 {index}: API 호출 {calls}회 · {doing}"
active_ago: " · {seconds}초 전 활동"
approve:
own_card: "검토자가 거부한 요청은 해당 카드에서 응답해야 합니다."
no_pending: "승인 대기 중인 명령이 없습니다."
once_singular: "✅ 명령이 승인되었습니다. 에이전트가 재개됩니다..."
once_plural: "✅ 명령이 승인되었습니다 ({count}개). 에이전트가 재개됩니다..."
Expand Down
1 change: 1 addition & 0 deletions locales/pt.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - filho {index}: {calls} chamadas de API · {doing}"
active_ago: " · ativo há {seconds}s"
approve:
own_card: "Os pedidos rejeitados pelo revisor devem ser respondidos no respetivo cartão."
no_pending: "Não há nenhum comando pendente para aprovar."
once_singular: "✅ Comando aprovado. O agente está a retomar..."
once_plural: "✅ Comandos aprovados ({count} comandos). O agente está a retomar..."
Expand Down
1 change: 1 addition & 0 deletions locales/ru.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - потомок {index}: {calls} вызовов API · {doing}"
active_ago: " · активен {seconds}с назад"
approve:
own_card: "На запросы, отклонённые проверяющим, нужно отвечать в их собственной карточке."
no_pending: "Нет команды, ожидающей одобрения."
once_singular: "✅ Команда одобрена. Агент возобновляет работу..."
once_plural: "✅ Команды одобрены ({count} команд). Агент возобновляет работу..."
Expand Down
1 change: 1 addition & 0 deletions locales/tr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - alt ajan {index}: {calls} API çağrısı · {doing}"
active_ago: " · {seconds} sn önce aktifti"
approve:
own_card: "İnceleyicinin reddettiği istekler kendi kartlarında yanıtlanmalıdır."
no_pending: "Onaylanacak bekleyen komut yok."
once_singular: "✅ Komut onaylandı. Ajan devam ediyor..."
once_plural: "✅ Komutlar onaylandı ({count} komut). Ajan devam ediyor..."
Expand Down
1 change: 1 addition & 0 deletions locales/uk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ gateway:
child_row: " - дочірній {index}: {calls} api-викликів · {doing}"
active_ago: " · активний {seconds}с тому"
approve:
own_card: "На запити, відхилені перевіряльником, потрібно відповідати на їхній власній картці."
no_pending: "Немає команди на схвалення."
once_singular: "✅ Команду схвалено. Агент відновлює роботу…"
once_plural: "✅ Команди схвалено ({count} команд). Агент відновлює роботу…"
Expand Down
Loading
Loading