Перейти к содержимому

Нужен human-in-the-loop

Хорошие AI-агенты не applay irreversible изменения молча: git push --force, DROP TABLE, миграция БД, выкат на прод — всё это требует человеческого approve. Plain stdout-prompt в этой ситуации работает плохо: вы где-то в кафе, ноутбук закрыт, агент висит и сжигает context window.

Notifly закрывает это двумя способами: штатным вопросом Ask с кнопками (без своего сервера — см. ниже) или парой push + собственный публичный callback-URL.

Минимальный шаблон: «нажми кнопку, чтобы продолжить»

Заголовок раздела «Минимальный шаблон: «нажми кнопку, чтобы продолжить»»
import os, secrets, time, requests
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
NOTIFLY = (os.environ["NOTIFLY_URL"], os.environ["NOTIFLY_TOKEN"])
def ask_human(question, callback_base):
"""
Поднимает локальный httpd на свободном порту, ждёт approve/deny.
callback_base — публичный URL (через Cloudflare Tunnel / ngrok / fly.io),
чтобы можно было нажать с телефона.
"""
token = secrets.token_urlsafe(8)
answer = {}
class H(BaseHTTPRequestHandler):
def do_GET(self):
if token not in self.path:
self.send_response(404); self.end_headers(); return
answer["v"] = "approve" if "approve" in self.path else "deny"
self.send_response(200); self.end_headers()
self.wfile.write(b"OK, you can close this tab.")
def log_message(self, *a, **kw): pass
httpd = HTTPServer(("127.0.0.1", 0), H)
port = httpd.server_address[1]
Thread(target=httpd.serve_forever, daemon=True).start()
base = callback_base.rstrip("/")
msg = (f"{question}\n\n"
f"✅ {base}/approve?t={token}\n"
f"❌ {base}/deny?t={token}")
requests.post(f"{NOTIFLY[0]}/message",
params={"token": NOTIFLY[1]},
json={"title": "🤖 Нужен approve", "message": msg, "priority": 9},
timeout=5)
deadline = time.time() + 30*60
while time.time() < deadline and "v" not in answer:
time.sleep(2)
httpd.shutdown()
return answer.get("v", "timeout")
# использование:
verdict = ask_human(
"Применить миграцию V234__drop_legacy_users.sql на prod?",
callback_base="https://my-tunnel.example.com",
)
if verdict != "approve":
raise SystemExit(f"abort: {verdict}")

Cloudflare Tunnel, ngrok http 0, fly machine — любой простой способ публикации локального порта. Если у вас уже есть прод-URL — лучше использовать его (туда же роутить /approve//deny).

Публиковать порт не обязательно: задайте закрытый вопрос с вариантами ответа. Он приходит обычным уведомлением канала, а ответить можно кнопкой прямо из уведомления на Android, в веб-админке или по ссылке в письме, Telegram, Slack и других каналах эскалации. Агент просто опрашивает ответ:

import os, time, requests
URL = os.environ["NOTIFLY_URL"] # https://api.notifly.ru
H = {"X-Notifly-Key": os.environ["NOTIFLY_TOKEN"]}
def ask_human(question, timeout_sec=30*60):
qid = requests.post(f"{URL}/ask", headers=H, timeout=10, json={
"title": "🤖 Нужен approve",
"question": question,
"answer_options": ["approve", "deny"],
"timeout_sec": timeout_sec,
"default_answer": "deny", # никто не ответил — отказ (fail-closed)
"priority": 9,
}).json()["id"]
while True:
r = requests.get(f"{URL}/ask-question/{qid}", headers=H, timeout=10)
if r.status_code == 200:
return r.json()["answer"]
if r.status_code == 410: # вопрос истёк или отменён
return "timeout"
time.sleep(5) # 204 — ответа ещё нет

Вместо поллинга можно ждать сигнал по WebSocket или принять callback_url — см. Вопросы в каналах.

Многие CLI-агенты (Claude Code, Codex CLI, Aider) выводят в stdout строку вроде Approve? [y/n]. Самый простой подход — обернуть их через expect:

#!/usr/bin/expect -f
spawn aider --auto-commit ...
expect {
-re "Approve\\? \\[y/n\\]" {
exec curl -fsS -X POST "$env(NOTIFLY_URL)/message?token=$env(NOTIFLY_TOKEN)" \
-H "Content-Type: application/json" \
-d "{\"title\":\"🤖 Aider ждёт approve\",\"message\":\"...\",\"priority\":9}"
interact # передаём управление пользователю в ssh
}
eof
}
  • Что именно делаем. Команда, путь до файла, имя миграции, branch.
  • Что произойдёт при deny. Чтобы вы могли уверенно нажать «нет».
  • Diff / preview, если возможно. В extras можно положить ссылку на gist.
  • Дедлайн. «Через 30 минут — abort», тогда из бара можно ничего не делать.