diff --git a/README.md b/README.md index b211b45..b119e05 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Strands Harness SDK как исполнительное ядро, модели - `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) - `harness/commands.py` — command side +- `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс) - `cli.py` — init/task/task-await/tasks/status/events/requeue ## CLI @@ -87,6 +88,24 @@ journalctl -u simbiot-harness -f отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее). Белый список чатов в `.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). + ## Дашборд задач Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + diff --git a/cli.py b/cli.py index cb95c79..45a62e2 100644 --- a/cli.py +++ b/cli.py @@ -48,6 +48,10 @@ def main(): p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") 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.add_argument("reason") @@ -152,6 +156,19 @@ def main(): from harness import tg_inbox r = tg_inbox.notify_chat(args.text) 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": es.init_db() eid, to_level = commands.raise_escalation( diff --git a/events.db b/events.db new file mode 100644 index 0000000..e69de29 diff --git a/harness/events.db b/harness/events.db new file mode 100644 index 0000000..e69de29 diff --git a/harness/scheduler.py b/harness/scheduler.py index 89b13fc..98eeb24 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -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() 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"))] if inbox: lines = [] @@ -292,7 +298,12 @@ def process_level(level_cfg, model: dict): def _reflex_tick(level_cfg, seq: int, model: dict): - """Секундный рефлекс: телеметрия + алерты, без LLM.""" + """Секундный рефлекс: телеметрия + алерты + без-LLM команды, без LLM. + + Команды бота (/лестница, /статус, /задачи, /помощь) — мгновенные + ответы из read model (t-260929210755): мин-уровень на них не + тратится, ReplySent закрывает входящее сразу после отправки. + """ import os load = None try: @@ -306,6 +317,17 @@ def _reflex_tick(level_cfg, seq: int, model: dict): thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8) if load is not None and load >= 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) return payload, None diff --git a/harness/tg_commands.py b/harness/tg_commands.py new file mode 100644 index 0000000..0e27f62 --- /dev/null +++ b/harness/tg_commands.py @@ -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 [] \ No newline at end of file diff --git a/harness/tg_inbox.db b/harness/tg_inbox.db new file mode 100644 index 0000000..e69de29 diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index cb381b0..df99ccd 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -13,6 +13,7 @@ import sys import time import urllib.request import urllib.parse +import urllib.error from . import es @@ -43,13 +44,25 @@ def _api(method: str, params: dict, timeout: int = 35): return json.loads(r.read().decode()) -def send_message(chat_id, text: str, reply_to=None): - """Отправка сообщения (используется tg-reply и алертами).""" +def send_message(chat_id, text: str, reply_to=None, timeout: int = 35): + """Отправка сообщения (используется tg-reply и алертами). + + timeout: сек-тик (без-LLM команды) передаёт короткий — рефлекс не + должен висеть на TG дольше пары секунд. + """ _load_env() params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} if 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): @@ -101,12 +114,13 @@ def receive_message(update: dict): 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 (закрывает входящее). Перед отправкой шлём sendChatAction typing — в TG видно «печатает…». sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для минутного пульса (типичный лаг ответа < 5с после начала отправки). + timeout — как в send_message (короткий для сек-рефлекса). """ model = es.run_projections() 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) except Exception: 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", { "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: raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env") 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 print("[tg] инбокс-поллер запущен", flush=True) while True: