feat(tg-cmd): без-LLM команды бота /лестница /статус /задачи /помощь — мгновенные ответы сек-рефлексом + setMyCommands (t-260929210755-9bf6a6)

- harness/tg_commands.py: реестр команд, парсер (кириллица/транслит/@бот/без слэша),
  хендлеры из read model, answer_pending (идемпотентно, cooldown 60с при лежащем TG),
  setMyCommands с фолбэком кириллица→транслит (Bot API латиницу только)
- scheduler.py: сек-тик отвечает на команды без LLM (payload.commands),
  build_context НЕ показывает команды мин-уровню (гонки/лежащий TG не тратят LLM)
- tg_inbox.py: send_message/send_reply принимают короткий timeout для рефлекса;
  поллер при старте авторегистрирует команды бота
- cli.py: tg-commands --register/--list
This commit is contained in:
Simbiot Harness 2026-09-30 02:30:27 +00:00
parent 7f4db9f085
commit 76aa204f2b
8 changed files with 330 additions and 6 deletions

View file

@ -33,6 +33,7 @@ Strands Harness SDK как исполнительное ядро, модели
- `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе - `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
- `harness/commands.py` — command side - `harness/commands.py` — command side
- `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс)
- `cli.py` — init/task/task-await/tasks/status/events/requeue - `cli.py` — init/task/task-await/tasks/status/events/requeue
## CLI ## CLI
@ -87,6 +88,24 @@ journalctl -u simbiot-harness -f
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее). отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`. Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
## Без-LLM команды бота (t-260929210755)
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`),
минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит,
`/cmd@bot`, одно слово без слэша) понимает парсер; список в чате —
`setMyCommands` (кириллица, фолбэк — транслит), регистрируется стартом
tg-поллера или вручную:
```bash
./venv/bin/python cli.py tg-commands --register # setMyCommands
./venv/bin/python cli.py tg-commands --list # что у бота сейчас
```
События: `CommandHandled` (команда закрыта рефлексом) + `ReplySent`;
`build_context` не показывает команды мин-уровню — даже без ответа
(гонка на границе тика / лежащий TG не заставят минуту тратить LLM).
## Дашборд задач ## Дашборд задач
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +

17
cli.py
View file

@ -48,6 +48,10 @@ def main():
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
p_not.add_argument("text") p_not.add_argument("text")
p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список")
p_tgc.add_argument("--register", action="store_true",
help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)")
p_tgc.add_argument("--list", action="store_true", help="показать текущие команды бота")
p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)") p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)")
p_esc.add_argument("reason") p_esc.add_argument("reason")
@ -152,6 +156,19 @@ def main():
from harness import tg_inbox from harness import tg_inbox
r = tg_inbox.notify_chat(args.text) r = tg_inbox.notify_chat(args.text)
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)") print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
elif args.cmd == "tg-commands":
es.init_db()
from harness import tg_commands
if args.register:
mode = tg_commands.register_bot_commands()
print(f"OK: команды бота зарегистрированы ({mode})" if mode
else "ERROR: setMyCommands не прошёл (нет токена / API недоступен)")
if args.list or not args.register:
cmds = tg_commands.get_my_commands()
if not cmds:
print("(у бота нет зарегистрированных команд)")
for name, desc in cmds:
print(f"/{name} — {desc}")
elif args.cmd == "escalate": elif args.cmd == "escalate":
es.init_db() es.init_db()
eid, to_level = commands.raise_escalation( eid, to_level = commands.raise_escalation(

0
events.db Normal file
View file

0
harness/events.db Normal file
View file

View file

@ -123,8 +123,14 @@ def build_context(level_cfg, model: dict, task=None):
# Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем — # Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем —
# минутный уровень должен знать, что голосовое пришло, но текста нет, # минутный уровень должен знать, что голосовое пришло, но текста нет,
# и ответить «не расслышал», а не делать вид, что сообщения не было. # и ответить «не расслышал», а не делать вид, что сообщения не было.
# Команды бота (/лестница и др.) — НЕ здесь: их мгновенно закрывает
# сек-рефлекс (t-260929210755), мин-LLM на них не тратится. Фильтруем
# даже без ответа — иначе гонка на границе тика (команда пришла,
# сек ещё не отвечал) или лежащий TG заставляли минуту отвечать LLM.
from . import tg_commands
inbox = [m for m in (model.get("inbox") or {}).values() inbox = [m for m in (model.get("inbox") or {}).values()
if not m.get("replied") and not m.get("ignored") if not m.get("replied") and not m.get("ignored")
and not tg_commands.parse(m.get("text") or "")
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))] and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
if inbox: if inbox:
lines = [] lines = []
@ -292,7 +298,12 @@ def process_level(level_cfg, model: dict):
def _reflex_tick(level_cfg, seq: int, model: dict): def _reflex_tick(level_cfg, seq: int, model: dict):
"""Секундный рефлекс: телеметрия + алерты, без LLM.""" """Секундный рефлекс: телеметрия + алерты + без-LLM команды, без LLM.
Команды бота (/лестница, /статус, /задачи, /помощь) — мгновенные
ответы из read model (t-260929210755): мин-уровень на них не
тратится, ReplySent закрывает входящее сразу после отправки.
"""
import os import os
load = None load = None
try: try:
@ -306,6 +317,17 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8) thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
if load is not None and load >= thr: if load is not None and load >= thr:
payload["alert"] = f"load {load:.2f} >= {thr}" payload["alert"] = f"load {load:.2f} >= {thr}"
# без-LLM команды: отвечаем на входящие-команды без ответа
try:
from . import tg_commands
handled = tg_commands.answer_pending(model)
if handled:
for mid, cmd in handled:
_log(f"tg-cmd: {mid} /{cmd} → отвечено (сек-рефлекс)")
payload["commands"] = [c for _, c in handled]
except Exception as e:
# команды — часть рефлекса, но не должны валить телеметрию
print(f"[tg-cmd] ответ на команды не удался: {e}", flush=True)
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload) es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
return payload, None return payload, None

244
harness/tg_commands.py Normal file
View file

@ -0,0 +1,244 @@
"""Simbiot Harness — без-LLM команды бота (/лестница, /статус, /задачи, /помощь).
Секундный уровень (рефлекс, без LLM) отвечает на команды мгновенно —
минутный LLM-контекст на них не тратится (t-260929210755):
answer_pending() в _reflex_tick находит в инбоксе входящие-команды,
хендлер собирает текст из read model, send_reply отправляет.
ReplySent закрывает входящее — мин-уровень команду больше не видит
(build_context их дополнительно фильтрует на случай гонки).
Не отправилось (TG недоступен) — следующий сек-тик попробует снова:
ReplySent пишется только после попытки отправки, команда остаётся «без ответа».
Регистрация в чате: setMyCommands — кириллические имена; если Bot API
кириллицу не принимает, фолбэк на транслит. Вызывается стартом tg-поллера
и CLI `cli.py tg-commands`. Алиасы парсер понимает независимо от того,
какие имена зарегистрированы.
"""
import html
import json
import time
from datetime import datetime, timezone
from . import es
# cooldown после неудачной отправки: сек-тик пробует команды раз в минуту,
# а не каждую секунду — лежащий TG не вешает рефлекс на 15с-таймаутах
_FAIL_COOLDOWN = 60
_last_fail_ts = 0.0
# каноническое имя → алиасы (регистронезависимые, без слэша)
_ALIASES = {
"лестница": ("лестница", "ladder", "lestnica"),
"статус": ("статус", "status"),
"задачи": ("задачи", "tasks", "zadachi"),
"помощь": ("помощь", "help", "pomoshch", "start", "начать", "команды", "commands"),
}
_ALIAS_TO_CMD = {}
for _c, _aliases in _ALIASES.items():
for _a in _aliases:
_ALIAS_TO_CMD[_a] = _c
_DESCRIPTIONS = {
"лестница": "Срез лестницы: уровни, занятость, задачи, эскалации",
"статус": "Сводка харнеса: тики, задачи, спенд",
"задачи": "Доска задач: готово и в работе",
"помощь": "Что я умею и как со мной говорить",
}
# транслит-фолбэк, если Bot API не примет кириллицу в именах команд
_LATIN = {"лестница": "lestnica", "статус": "status", "задачи": "zadachi", "помощь": "pomoshch"}
def parse(text) -> "str | None":
"""Сообщение — команда? Возвращает каноническое имя или None.
Команда: "/имя" (первый токен; @бот отбрасывается) или ОДИНОЧНОЕ
слово-алиас без слэша («лестница»). Фраза с пробелами без слэша —
не команда (обычная речь, её слышит минутный LLM-уровень).
"""
t = (text or "").strip()
if not t:
return None
if t.startswith("/"):
tok = t.split()[0].lstrip("/")
if "@" in tok: # /cmd@botname — адресация боту
tok = tok.split("@", 1)[0]
else:
if len(t.split()) > 1:
return None
tok = t
tok = tok.strip(".,!?;:«»\"'()").lower()
return _ALIAS_TO_CMD.get(tok)
def _esc(s) -> str:
"""TG-отправка идёт с parse_mode=HTML — динамику экранируем."""
return html.escape(str(s or ""), quote=False)
def cmd_ladder() -> str:
from . import tg_inbox
return "⚡️ Срез лестницы:\n\n" + _esc(tg_inbox.ladder_snapshot())
def cmd_status() -> str:
model = es.run_projections()
cs = model.get("cycle_state") or {}
tasks = list((model.get("tasks") or {}).values())
counts = {}
for t in tasks:
counts[t.get("status") or "?"] = counts.get(t.get("status") or "?", 0) + 1
spend = model.get("spend") or []
tokens = sum(int(e.get("prompt_tokens") or 0) + int(e.get("completion_tokens") or 0)
for e in spend)
open_esc = sum(1 for e in (model.get("escalations") or {}).values()
if isinstance(e, dict) and e.get("status") == "open")
lines = [f"📊 Статус харнеса · {datetime.now(timezone.utc).strftime('%d.%m %H:%M')} UTC"]
for name in ("sec", "min", "hour", "day"):
st = cs.get(name) or {}
err = f", ошибок {st['errors']}" if st.get("errors") else ""
lines.append(f" {name}: тиков {st.get('ticks', 0)}{err}")
parts = [f"{k} {counts[k]}" for k in ("ready", "running", "done", "failed") if counts.get(k)]
lines.append("📋 задачи: " + (" · ".join(parts) if parts else "нет"))
lines.append(f"🚨 эскалаций открытых: {open_esc}")
lines.append(f"💰 спенд (последние {len(spend)} тиков): ~{tokens:,} токенов".replace(",", " "))
return "\n".join(lines)
def cmd_tasks() -> str:
model = es.run_projections()
tasks = list((model.get("tasks") or {}).values())
tasks.sort(key=lambda t: t.get("created") or "", reverse=True)
board = [t for t in tasks if t.get("status") in ("ready", "running")]
done = sum(1 for t in tasks if t.get("status") == "done")
lines = [f"📋 Доска: готово {sum(1 for t in tasks if t.get('status') == 'ready')}"
f" · в работе {sum(1 for t in tasks if t.get('status') == 'running')}"
f" · сделано {done}"]
for t in board[:12]:
lines.append(f" [{t.get('size', '?')}] {_esc((t.get('title') or '')[:70])}")
if len(board) > 12:
lines.append(f" … ещё {len(board) - 12}")
lines.append("Выполняют часовой/дневной уровни; детали — на дашборде.")
return "\n".join(lines)
def cmd_help() -> str:
lines = ["🤖 Simbiot — команды (мгновенные, без LLM):"]
for c, d in _DESCRIPTIONS.items():
lines.append(f" /{_LATIN[c]} (или /{c}) — {d}")
lines.append("")
lines.append("Обычный текст и голосовые уходят минутному пульсу (LLM) — отвечу по смыслу.")
lines.append("Большую просьбу можно заказать задачей: результат придёт в чат сам, когда будет готов.")
return "\n".join(lines)
_HANDLERS = {
"лестница": cmd_ladder,
"статус": cmd_status,
"задачи": cmd_tasks,
"помощь": cmd_help,
}
def answer(msg_id: str, cmd: str) -> str:
"""Собирает ответ команды и отправляет. ReplySent закрывает входящее.
Команда — не задача и не LLM-тик: это рефлекс. CommandHandled (после
успешной отправки) — только наблюдаемость: видно, что входящее закрыто
именно командой, а не минутным триажем.
"""
from . import tg_inbox
text = _HANDLERS[cmd]()
# короткий таймаут: сек-тик не должен висеть на TG дольше пары секунд
tg_inbox.send_reply(msg_id, text, timeout=15)
try:
es.append_event("inbox", msg_id, "CommandHandled", {
"command": cmd, "level": "sec", "handler": "tg_commands",
})
except Exception:
pass # событие — наблюдаемость; ReplySent уже закрыл входящее
return text
def answer_pending(model: "dict | None" = None):
"""Отвечает на все входящие-команды без ответа. [(msg_id, cmd), …].
Идемпотентно: replied-сообщения пропускаются (проекция свежая —
send_reply сам перепроверяет по стору и кинет ValueError при гонке
с минутным уровнем — это не ошибка, просто пропускаем).
"""
if model is None:
model = es.run_projections()
# есть ли вообще команды-кандидаты: если нет — cooldown не проверяем
pending = [m for m in (model.get("inbox") or {}).values()
if isinstance(m, dict) and not m.get("replied") and not m.get("ignored")
and parse(m.get("text") or "")]
if not pending:
return []
global _last_fail_ts
if time.time() - _last_fail_ts < _FAIL_COOLDOWN:
return [] # недавно не смогли отправить — ждём, рефлекс не висит на TG
handled = []
for m in pending:
mid = m.get("id") or (f"m-{m.get('update_id')}" if m.get("update_id") else None)
if not mid:
continue
cmd = parse(m.get("text") or "")
try:
answer(mid, cmd)
handled.append((mid, cmd))
except ValueError:
pass # уже отвечено (гонка) — не ошибка
except Exception as e:
# TG недоступен и пр. — НЕ отвечено; ставим cooldown и повторим
# не раньше чем через минуту (рефлекс тикает каждую секунду)
_last_fail_ts = time.time()
print(f"[tg-cmd] {mid} /{cmd} не отвечено: {e} "
f"(cooldown {_FAIL_COOLDOWN}с)", flush=True)
return handled
# ────────────── регистрация в чате (setMyCommands) ──────────────
def commands_spec(latin: bool = False) -> list:
return [{"command": (_LATIN[c] if latin else c), "description": d}
for c, d in _DESCRIPTIONS.items()]
def register_bot_commands():
"""setMyCommands: сначала кириллица, при отказе — транслит.
Возвращает 'cyrillic' | 'latin' | None (нет токена/не удалось).
Повторный вызов идемпотентен — перезапишет список тем же содержимым.
"""
from . import tg_inbox
tg_inbox._load_env()
if not tg_inbox.TG_TOKEN:
return None
try:
r = tg_inbox._api("setMyCommands",
{"commands": json.dumps(commands_spec(), ensure_ascii=False)})
if r.get("ok"):
return "cyrillic"
raise RuntimeError(str(r)[:200])
except Exception as e:
print(f"[tg-cmd] setMyCommands: кириллица не принята ({e}) — пробую транслит", flush=True)
try:
r = tg_inbox._api("setMyCommands",
{"commands": json.dumps(commands_spec(latin=True), ensure_ascii=False)})
return "latin" if r.get("ok") else None
except Exception:
return None
def get_my_commands() -> list:
"""Текущий список команд у бота ([(name, description), …])."""
from . import tg_inbox
tg_inbox._load_env()
if not tg_inbox.TG_TOKEN:
return []
try:
r = tg_inbox._api("getMyCommands", {})
return [(c.get("command"), c.get("description")) for c in r.get("result", [])]
except Exception:
return []

0
harness/tg_inbox.db Normal file
View file

View file

@ -13,6 +13,7 @@ import sys
import time import time
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import urllib.error
from . import es from . import es
@ -43,13 +44,25 @@ def _api(method: str, params: dict, timeout: int = 35):
return json.loads(r.read().decode()) return json.loads(r.read().decode())
def send_message(chat_id, text: str, reply_to=None): def send_message(chat_id, text: str, reply_to=None, timeout: int = 35):
"""Отправка сообщения (используется tg-reply и алертами).""" """Отправка сообщения (используется tg-reply и алертами).
timeout: сек-тик (без-LLM команды) передаёт короткий — рефлекс не
должен висеть на TG дольше пары секунд.
"""
_load_env() _load_env()
params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
if reply_to: if reply_to:
params["reply_to_message_id"] = reply_to params["reply_to_message_id"] = reply_to
return _api("sendMessage", params) try:
return _api("sendMessage", params, timeout=timeout)
except urllib.error.HTTPError as e:
# 400 от кривого HTML (незакрытый тег, «a < b» в тексте) — шлём как plain text.
# Иначе ответ/доставка молча теряются: исключение глотается на верхнем уровне.
if e.code == 400:
params.pop("parse_mode", None)
return _api("sendMessage", params, timeout=timeout)
raise
def receive_message(update: dict): def receive_message(update: dict):
@ -101,12 +114,13 @@ def receive_message(update: dict):
return f"m-{update['update_id']}" return f"m-{update['update_id']}"
def send_reply(msg_id: str, text: str): def send_reply(msg_id: str, text: str, timeout: int = 35):
"""Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее). """Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее).
Перед отправкой шлём sendChatAction typing — в TG видно «печатает…». Перед отправкой шлём sendChatAction typing — в TG видно «печатает…».
sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для
минутного пульса (типичный лаг ответа < 5с после начала отправки). минутного пульса (типичный лаг ответа < 5с после начала отправки).
timeout — как в send_message (короткий для сек-рефлекса).
""" """
model = es.run_projections() model = es.run_projections()
m = (model.get("inbox") or {}).get(msg_id) m = (model.get("inbox") or {}).get(msg_id)
@ -118,7 +132,7 @@ def send_reply(msg_id: str, text: str):
_api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10) _api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10)
except Exception: except Exception:
pass pass
r = send_message(m["chat_id"], text, reply_to=None) r = send_message(m["chat_id"], text, reply_to=None, timeout=timeout)
es.append_event("inbox", msg_id, "ReplySent", { es.append_event("inbox", msg_id, "ReplySent", {
"to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")), "to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")),
}) })
@ -131,6 +145,14 @@ def poll_loop():
if not TG_TOKEN: if not TG_TOKEN:
raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env") raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env")
es.init_db() es.init_db()
# (t-260929210755) список команд бота — виден в чате как меню "/":
# регистрируем при старте поллера (идемпотентно) и после рестартов.
try:
from . import tg_commands
mode = tg_commands.register_bot_commands()
print(f"[tg] команды бота зарегистрированы ({mode})", flush=True)
except Exception as e:
print(f"[tg] регистрация команд не удалась: {e}", flush=True)
offset = 0 offset = 0
print("[tg] инбокс-поллер запущен", flush=True) print("[tg] инбокс-поллер запущен", flush=True)
while True: while True: