AITURK IDE 1.0.0-beta.18: gorunmeyen onay beklemesi yerine aninda "onay gerekli"
A one-shot run (hermes chat --query-file ... -Q, stdin closed, no window)
still installs the prompt_toolkit approval callback but never starts the
prompt UI. The protected instruction-file gate (AGENTS.md / CLAUDE.md /
SOUL.md ...) took the registered callback for a reachable human and waited
out approvals.timeout on a panel painted nowhere. With the profile value of
300000 s the stage job hung for good.
- Gate: in a one-shot run, or when only an automatic approver
(subagent_auto_approve) is attached, return BLOCKED / APPROVAL REQUIRED
immediately instead of prompting. Human consent is still required every
time; yolo and approvals.single_query_mode still do not open this gate.
- One-shot exit prints an "approval_required: {json}" line on stderr
(session_id, tool_call_id, request_id, targets, reason, applied=false)
so the launcher can tell "blocked, needs a human" from "still working".
- execute_code: an approval that arrives after the calling script stopped
waiting (300 s reply timeout, script run timeout, retired kernel cell)
is not applied.
- Same invisible wait removed from the computer_use and memory-write
prompts in a one-shot run. The one-shot marker is no longer inherited by
child processes.
- BLOCKED text no longer breaks on a target path containing braces.
Long-running tools are untouched: no general tool timeout was shortened.
Kart: turkservis-20261007-1421-2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
This commit is contained in:
1 parent
58dbb4523b
commit
7e4cc0915c
13 files changed
+886
-16
No files matched your search
@@ -78,6 +78,115 @@ _hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.Con
|
||||
)
|
||||
|
||||
|
||||
# Absolute ``time.monotonic()`` instant after which whoever issued the
|
||||
# current tool call has stopped waiting for its result. Set by the
|
||||
# execute_code RPC dispatchers, whose sandbox client gives up after a fixed
|
||||
# reply timeout. An approval that resolves after this instant must NOT be
|
||||
# acted on: the caller already reported the call as failed, so applying it
|
||||
# would be a write the agent believes never happened (and may redo).
|
||||
# The reply timeout is not the only way a caller goes away: the script can
|
||||
# hit its own run timeout or be interrupted first. ``gone`` is a probe the
|
||||
# dispatcher supplies for that ("has my script/cell already ended?").
|
||||
# Thread-local on purpose: the dispatcher and the tool handler it calls run
|
||||
# on the same thread, whatever context the handler is re-bound to.
|
||||
_approval_reply_deadline = threading.local()
|
||||
|
||||
|
||||
def set_approval_reply_deadline(deadline: Optional[float], gone=None):
|
||||
"""Bind the caller's reply window for this thread; return the prior binding.
|
||||
|
||||
``deadline`` is monotonic seconds (None = unbounded); ``gone`` is an
|
||||
optional zero-argument callable that turns true once the caller has
|
||||
ended. Pass the returned value to :func:`restore_approval_reply_deadline`.
|
||||
"""
|
||||
previous = (
|
||||
getattr(_approval_reply_deadline, "value", None),
|
||||
getattr(_approval_reply_deadline, "gone", None),
|
||||
)
|
||||
_approval_reply_deadline.value = deadline
|
||||
_approval_reply_deadline.gone = gone
|
||||
return previous
|
||||
|
||||
|
||||
def restore_approval_reply_deadline(previous) -> None:
|
||||
"""Undo :func:`set_approval_reply_deadline` / :func:`also_gone_when`."""
|
||||
deadline, gone = previous if previous else (None, None)
|
||||
_approval_reply_deadline.value = deadline
|
||||
_approval_reply_deadline.gone = gone
|
||||
|
||||
|
||||
def also_gone_when(probe):
|
||||
"""Add another "caller has ended" probe, keeping the current window."""
|
||||
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||
current = getattr(_approval_reply_deadline, "gone", None)
|
||||
|
||||
def gone() -> bool:
|
||||
return bool(probe()) or bool(current and current())
|
||||
|
||||
return set_approval_reply_deadline(deadline, gone)
|
||||
|
||||
|
||||
def approval_reply_window_remaining() -> Optional[float]:
|
||||
"""Seconds left before the caller stops waiting, or None when unbounded."""
|
||||
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||
if deadline is None:
|
||||
return None
|
||||
return deadline - time.monotonic()
|
||||
|
||||
|
||||
def approval_arrived_too_late() -> bool:
|
||||
"""True when the caller of the current tool call has stopped waiting."""
|
||||
gone = getattr(_approval_reply_deadline, "gone", None)
|
||||
if gone is not None:
|
||||
try:
|
||||
if gone():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
remaining = approval_reply_window_remaining()
|
||||
return remaining is not None and remaining <= 0
|
||||
|
||||
|
||||
# Approvals that could not be put in front of a human because the run has
|
||||
# no interactive channel (single-query ``chat -Q`` stage runs, scripts).
|
||||
# The single-query CLI reports them on stderr as ``approval_required:``
|
||||
# lines so the coordinating process gets a machine-readable outcome
|
||||
# instead of inferring one from prose. Bounded: long-lived processes that
|
||||
# never drain it must not grow without limit.
|
||||
_human_approval_required: list[dict] = []
|
||||
_HUMAN_APPROVAL_REQUIRED_MAX = 50
|
||||
_human_approval_required_lock = threading.Lock()
|
||||
|
||||
|
||||
def note_human_approval_required(kind: str, targets: list[str], reason: str) -> dict:
|
||||
"""Record an approval the run could not present to a human.
|
||||
|
||||
Captures the active session/tool-call identity so the coordinator can
|
||||
tie the outcome to the exact job and resume the same session.
|
||||
"""
|
||||
record = {
|
||||
"kind": kind,
|
||||
"targets": list(dict.fromkeys(str(t) for t in targets)),
|
||||
"reason": reason,
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"tool_call_id": _approval_tool_call_id.get() or "",
|
||||
"session_id": _approval_session_id.get() or os.getenv("HERMES_SESSION_ID", ""),
|
||||
"applied": False,
|
||||
}
|
||||
with _human_approval_required_lock:
|
||||
_human_approval_required.append(record)
|
||||
del _human_approval_required[:-_HUMAN_APPROVAL_REQUIRED_MAX]
|
||||
return dict(record)
|
||||
|
||||
|
||||
def drain_human_approval_required() -> list[dict]:
|
||||
"""Return and clear the recorded approval-required outcomes."""
|
||||
with _human_approval_required_lock:
|
||||
records = list(_human_approval_required)
|
||||
_human_approval_required.clear()
|
||||
return records
|
||||
|
||||
|
||||
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
|
||||
"""Bind interactive mode for the current context (thread or asyncio task).
|
||||
|
||||
|
||||
@@ -716,6 +716,36 @@ def _call(tool_name, args):
|
||||
# RPC server (runs in a thread inside the parent process)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# How long the generated sandbox client waits for one tool-call reply
|
||||
# (``_sock.settimeout(300)`` / the file transport's 300s poll deadline).
|
||||
_RPC_REPLY_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def _bind_rpc_reply_deadline(call_start: float, stop_event=None):
|
||||
"""Tell the approval gates when this call's requester stops waiting.
|
||||
|
||||
The requester is gone at the reply timeout, or earlier when the script
|
||||
itself ended (run timeout, interrupt): ``stop_event`` is set then.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import set_approval_reply_deadline
|
||||
gone = stop_event.is_set if stop_event is not None else None
|
||||
return (True, set_approval_reply_deadline(
|
||||
call_start + _RPC_REPLY_TIMEOUT_SECONDS, gone))
|
||||
except Exception:
|
||||
return (False, None)
|
||||
|
||||
|
||||
def _restore_rpc_reply_deadline(previous) -> None:
|
||||
if not previous or not previous[0]:
|
||||
return
|
||||
try:
|
||||
from tools.approval import restore_approval_reply_deadline
|
||||
restore_approval_reply_deadline(previous[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Terminal parameters that must not be used from ephemeral sandbox scripts
|
||||
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
|
||||
|
||||
@@ -826,12 +856,18 @@ def _rpc_server_loop(
|
||||
# Dispatch through the standard tool handler.
|
||||
# Suppress stdout/stderr from internal tool handlers so
|
||||
# their status prints don't leak into the CLI spinner.
|
||||
# The sandbox client stops waiting for this reply after
|
||||
# _RPC_REPLY_TIMEOUT_SECONDS; an approval granted later
|
||||
# must not apply a write the script already saw fail.
|
||||
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
result = dispatch(tool_name, tool_args)
|
||||
except Exception as exc:
|
||||
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
|
||||
result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
|
||||
tool_args.pop(param, None)
|
||||
|
||||
# Dispatch through the standard tool handler
|
||||
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
tool_result = handle_function_call(
|
||||
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
|
||||
logger.error("Tool call failed in remote sandbox: %s",
|
||||
exc, exc_info=True)
|
||||
tool_result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
|
||||
@@ -322,9 +322,25 @@ class CellAuthority:
|
||||
set_sudo(self._sudo_cb)
|
||||
except Exception:
|
||||
previous = None
|
||||
# A cell that settled (timeout, interrupt, error) no longer waits for
|
||||
# this call: an approval granted afterwards must not be applied.
|
||||
reply_window = None
|
||||
try:
|
||||
from tools.approval import also_gone_when
|
||||
|
||||
reply_window = (also_gone_when(lambda: not self.active),)
|
||||
except Exception:
|
||||
reply_window = None
|
||||
try:
|
||||
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
|
||||
finally:
|
||||
if reply_window is not None:
|
||||
try:
|
||||
from tools.approval import restore_approval_reply_deadline
|
||||
|
||||
restore_approval_reply_deadline(reply_window[0])
|
||||
except Exception:
|
||||
pass
|
||||
if previous is not None and self._callback_setters is not None:
|
||||
set_approval, set_sudo = self._callback_setters
|
||||
try:
|
||||
|
||||
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
|
||||
# No CLI approval wired — default allow. Gateway approval is handled
|
||||
# one layer out via the normal tool-approval infra.
|
||||
return None
|
||||
try:
|
||||
from tools.approval import _is_single_query_approval_context
|
||||
unattended = _is_single_query_approval_context()
|
||||
except Exception:
|
||||
unattended = False
|
||||
if unattended:
|
||||
# One-shot run: the prompt callback is installed but its panel is
|
||||
# never shown, so asking would only wait out approvals.timeout.
|
||||
return json.dumps({
|
||||
"error": (
|
||||
"approval required but this is a one-shot run with no "
|
||||
"interactive user to approve it. Silence is not consent; "
|
||||
"do not retry without the user."
|
||||
),
|
||||
"action": action,
|
||||
})
|
||||
summary = _summarize_action(action, args)
|
||||
try:
|
||||
verdict = cb(action, args, summary)
|
||||
|
||||
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
|
||||
return "once"
|
||||
|
||||
|
||||
# Not a person: gates that require real human consent every time (protected
|
||||
# instruction files) must not accept this callback's answer.
|
||||
_subagent_auto_approve.unattended = True
|
||||
|
||||
|
||||
def _get_subagent_approval_callback():
|
||||
"""Return the callback to install into subagent worker threads.
|
||||
|
||||
|
||||
@@ -1546,6 +1546,10 @@ def _make_run_env(env: dict) -> dict:
|
||||
run_env[real_key] = v
|
||||
elif _is_hermes_internal_secret(k):
|
||||
continue
|
||||
elif k == "HERMES_SINGLE_QUERY_SESSION":
|
||||
# Describes this process only. A child (an interactive hermes
|
||||
# started from a one-shot run's terminal) sets its own.
|
||||
continue
|
||||
else:
|
||||
first_party = _is_terminal_first_party_env(k)
|
||||
passthrough = _is_passthrough(k)
|
||||
|
||||
+79
-14
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
|
||||
"required (not bypassed by auto-approve)."
|
||||
)
|
||||
display = f"<write to {targets}>"
|
||||
blocked = (
|
||||
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
|
||||
"{why} The user has NOT consented to this write. Do NOT retry it or "
|
||||
"attempt the same edit via another path (terminal, execute_code, "
|
||||
"etc.)."
|
||||
)
|
||||
class _Blocked:
|
||||
# Not str.format: a target path may itself contain braces.
|
||||
@staticmethod
|
||||
def format(why: str) -> str:
|
||||
return (
|
||||
"BLOCKED: write to protected agent-instruction file(s) "
|
||||
f"({targets}) {why} The user has NOT consented to this "
|
||||
"write. Do NOT retry it or attempt the same edit via "
|
||||
"another path (terminal, execute_code, etc.)."
|
||||
)
|
||||
|
||||
blocked = _Blocked
|
||||
|
||||
try:
|
||||
import tools.approval as _approval
|
||||
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
|
||||
return blocked.format(why="requires approval but the approval "
|
||||
"subsystem is unavailable.")
|
||||
|
||||
def _needs_human(reason: str, why: str) -> str:
|
||||
# No human could be shown this request. Record it so a one-shot
|
||||
# run can hand its coordinator a machine-readable outcome, and tell
|
||||
# the agent where a human CAN approve it instead of waiting on a
|
||||
# prompt nobody sees.
|
||||
try:
|
||||
_approval.note_human_approval_required(
|
||||
"protected_instruction_file", list(reasons), reason)
|
||||
except Exception:
|
||||
pass
|
||||
return blocked.format(
|
||||
why=why + " APPROVAL REQUIRED: a human must approve this from "
|
||||
"an interactive session (resume this same session in "
|
||||
"the desktop app or an interactive terminal and "
|
||||
"request the write again there).")
|
||||
|
||||
def _too_late() -> str | None:
|
||||
# The approval came back after whoever issued this call stopped
|
||||
# waiting (execute_code RPC reply timeout). The caller already saw
|
||||
# a failure, so applying the write now would be an edit the agent
|
||||
# believes never happened.
|
||||
try:
|
||||
late = _approval.approval_arrived_too_late()
|
||||
except Exception:
|
||||
late = False
|
||||
if not late:
|
||||
return None
|
||||
logger.warning(
|
||||
"Approval for protected instruction file(s) %s arrived after "
|
||||
"the caller stopped waiting; write not applied.", targets)
|
||||
return blocked.format(
|
||||
why="was approved only after the calling script had stopped "
|
||||
"waiting for the result, so the write was NOT applied. "
|
||||
"The approval is spent; a new request needs a new approval.")
|
||||
|
||||
# Gateway surface: block on the button round-trip when a notify callback
|
||||
# is registered for this session (Telegram/Discord/Slack). One-operation
|
||||
# only — no session/permanent buttons are offered.
|
||||
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
|
||||
session_key, notify_cb, approval_data, surface="gateway",
|
||||
)
|
||||
if decision.get("notify_failed"):
|
||||
return blocked.format(
|
||||
why="requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
return _needs_human(
|
||||
"approval_not_delivered",
|
||||
"requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
choice = decision.get("choice")
|
||||
if decision.get("resolved") and choice in {"once", "session", "always"}:
|
||||
# One-operation grant regardless of the tapped scope — nothing
|
||||
# is persisted for this gate.
|
||||
return None
|
||||
return _too_late()
|
||||
if not decision.get("resolved"):
|
||||
return blocked.format(
|
||||
why="approval prompt timed out without a user response. "
|
||||
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
|
||||
callback = None
|
||||
|
||||
if callback is not None:
|
||||
# A one-shot run (``chat -q`` / ``--query-file``) installs the
|
||||
# prompt callback but never starts the prompt UI: the panel would
|
||||
# be painted nowhere and the wait would last the whole
|
||||
# approvals.timeout with nobody able to answer. A registered
|
||||
# callback is not a reachable human. Fail closed NOW;
|
||||
# approvals.single_query_mode does not apply to this gate.
|
||||
try:
|
||||
unattended = _approval._is_single_query_approval_context()
|
||||
except Exception:
|
||||
unattended = False
|
||||
if unattended:
|
||||
return _needs_human(
|
||||
"no_interactive_user",
|
||||
"requires approval but this is a one-shot run with no "
|
||||
"interactive user to approve it.")
|
||||
if getattr(callback, "unattended", False):
|
||||
# An auto-answering callback (subagent auto-approve) is not a
|
||||
# person; this gate takes consent from a human only.
|
||||
return _needs_human(
|
||||
"no_interactive_user",
|
||||
"requires approval but only an automatic approver is "
|
||||
"attached to this run, not a person.")
|
||||
choice = _approval.prompt_dangerous_approval(
|
||||
display, description,
|
||||
allow_permanent=False,
|
||||
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
|
||||
)
|
||||
if choice in {"once", "session", "always"}:
|
||||
# One-operation grant; never persisted (see docstring).
|
||||
return None
|
||||
return _too_late()
|
||||
if choice == "timeout":
|
||||
return blocked.format(
|
||||
why="approval prompt timed out without a user response. "
|
||||
@@ -938,9 +1002,10 @@ def _request_protected_instruction_approval(
|
||||
|
||||
# No human channel at all (script, cron, background thread): fail
|
||||
# closed. Auto-approving here would recreate the persistence vector.
|
||||
return blocked.format(
|
||||
why="requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
return _needs_human(
|
||||
"no_approval_channel",
|
||||
"requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
|
||||
|
||||
def _check_protected_instruction_write(paths: list[str],
|
||||
|
||||
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
|
||||
review affordance (``/memory pending``) instead.
|
||||
* Scripts, cron, and background threads — no user present.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import _is_single_query_approval_context
|
||||
if _is_single_query_approval_context():
|
||||
# One-shot run: the callback exists but its panel is never
|
||||
# shown, so an inline prompt would wait with nobody to answer.
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
return _get_approval_callback() is not None
|
||||
|
||||
Reference in new issue
Block a user