diff --git a/README.md b/README.md index 3b3a308..5e1b597 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Strands Harness SDK как исполнительное ядро, модели `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, +`EscalationRaised`, `EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only, проекции пересобираются replay'ем, стейт = производное от событий. @@ -43,9 +44,22 @@ Strands Harness SDK как исполнительное ядро, модели ./venv/bin/python cli.py events -n 50 ./venv/bin/python cli.py requeue -r "причина" ./venv/bin/python cli.py promote -r "вне класса уровня" # S→M→L +./venv/bin/python cli.py escalate "причина" -f min -c "контекст" -s critical +./venv/bin/python cli.py escalations --open # открытые эскалации +./venv/bin/python cli.py escalation-ack -n "что сделал" # закрыть ./venv/bin/python cli.py tg-reply "текст" # ответ на входящее TG ``` +## Эскалации + +Важное с нижних уровней поднимается наверх событием `EscalationRaised` +(min→hour→day; day — вершина, эскалировать некуда). Открытые эскалации +инжектятся в контекст верхних уровней (`build_context`) в приоритетной секции +— нижний уровень не тонет в чужих проблемах, верхний видит выжимку, а не сырь. +Уровень-адресат закрывает эскалацию `escalation-ack` (событие +`EscalationAcknowledged`). Падение LLM-тика (`CycleError`) эскалируется +автоматически — scheduler поднимает warn-эскалацию на уровень выше. + ## Запуск ```bash diff --git a/__pycache__/cli.cpython-312.pyc b/__pycache__/cli.cpython-312.pyc index 0c60966..e409f33 100644 Binary files a/__pycache__/cli.cpython-312.pyc and b/__pycache__/cli.cpython-312.pyc differ diff --git a/architecture.html b/architecture.html index fb5323e..5907b1a 100644 --- a/architecture.html +++ b/architecture.html @@ -322,6 +322,8 @@
  • PolicyChanged {budgets, mandates}
  • BudgetSpent {level, tokens, $}
  • +
  • EscalationRaised {from_level, to_level, reason, context, severity} — важное с нижнего уровня наверх
  • +
  • EscalationAcknowledged {level, note} — верхний уровень принял/отработал
  • LevelPaused / LevelResumed
diff --git a/cli.py b/cli.py index 95666de..1191a3e 100644 --- a/cli.py +++ b/cli.py @@ -35,12 +35,29 @@ def main(): p_tgr.add_argument("msg_id", help="id входящего (m-NNN)") p_tgr.add_argument("text") + p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)") + p_esc.add_argument("reason") + p_esc.add_argument("-f", "--from-level", default="min", choices=["min", "hour"], + help="кто эскалирует") + p_esc.add_argument("-c", "--context", default="", help="контекст для верхнего уровня") + p_esc.add_argument("-s", "--severity", default="warn", choices=["info", "warn", "critical"]) + p_esc.add_argument("-t", "--task-id", default=None, help="связанная задача") + + p_escl = sub.add_parser("escalations", help="список эскалаций") + p_escl.add_argument("--open", action="store_true", help="только открытые") + + p_ack = sub.add_parser("escalation-ack", help="закрыть эскалацию (принята уровнем выше)") + p_ack.add_argument("esc_id") + p_ack.add_argument("-l", "--level", default="hour", help="кто принял") + p_ack.add_argument("-n", "--note", default="", help="что сделали с эскалацией") + sub.add_parser("status", help="состояние харнеса") sub.add_parser("tasks", help="доска задач") - p_ev = sub.add_parser("events", help="хвост стора") + p_ev = sub.add_parser("events", help="хвост стора (последние N событий)") p_ev.add_argument("-n", type=int, default=15) - p_ev.add_argument("--after", type=int, default=0) + p_ev.add_argument("--after", type=int, default=None, + help="вывести события ПОСЛЕ этого id (с головы, по возрастанию)") p_init = sub.add_parser("init", help="инициализация стора") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") @@ -70,6 +87,25 @@ def main(): from harness import tg_inbox mid = tg_inbox.send_reply(args.msg_id, args.text) print(f"OK: ответ на {mid} отправлен") + elif args.cmd == "escalate": + es.init_db() + eid, to_level = commands.raise_escalation( + args.from_level, args.reason, args.context, args.severity, args.task_id) + print(f"OK: эскалация {eid} → {to_level} ({args.severity})") + elif args.cmd == "escalations": + es.init_db() + rows = commands.list_escalations(only_open=args.open) + if not rows: + print("(эскалаций нет)") + for e in rows: + ctx = f" | {e.get('context','')[:60]}" if e.get("context") else "" + print(f"{e['id']} [{e.get('severity','info'):8}] {e.get('status','?'):5} " + f"{e.get('from_level')} → {e.get('to_level')} " + f"{e.get('reason','')[:70]}{ctx}") + elif args.cmd == "escalation-ack": + es.init_db() + eid = commands.acknowledge_escalation(args.esc_id, args.level, args.note) + print(f"OK: эскалация {eid} закрыта ({args.level})") elif args.cmd == "status": es.init_db() print(json.dumps(commands.status(), ensure_ascii=False, indent=1)) @@ -79,7 +115,13 @@ def main(): print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") elif args.cmd == "events": es.init_db() - for ev in es.get_events(args.after, args.n): + if args.after is None: + # хвост стора: последние N по возрастанию id + total = es.count_events() + evs = es.get_events(max(0, total - args.n), args.n) + else: + evs = es.get_events(args.after, args.n) + for ev in evs: d = ev["data"][:110] print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}") diff --git a/harness/__pycache__/__init__.cpython-312.pyc b/harness/__pycache__/__init__.cpython-312.pyc index 13b6a46..ee0f9de 100644 Binary files a/harness/__pycache__/__init__.cpython-312.pyc and b/harness/__pycache__/__init__.cpython-312.pyc differ diff --git a/harness/__pycache__/commands.cpython-312.pyc b/harness/__pycache__/commands.cpython-312.pyc index f519eef..d2eedc6 100644 Binary files a/harness/__pycache__/commands.cpython-312.pyc and b/harness/__pycache__/commands.cpython-312.pyc differ diff --git a/harness/__pycache__/es.cpython-312.pyc b/harness/__pycache__/es.cpython-312.pyc index 804bae8..88f0a08 100644 Binary files a/harness/__pycache__/es.cpython-312.pyc and b/harness/__pycache__/es.cpython-312.pyc differ diff --git a/harness/__pycache__/levels.cpython-312.pyc b/harness/__pycache__/levels.cpython-312.pyc index b367113..d99b55a 100644 Binary files a/harness/__pycache__/levels.cpython-312.pyc and b/harness/__pycache__/levels.cpython-312.pyc differ diff --git a/harness/__pycache__/runtime.cpython-312.pyc b/harness/__pycache__/runtime.cpython-312.pyc index c187164..aca8eba 100644 Binary files a/harness/__pycache__/runtime.cpython-312.pyc and b/harness/__pycache__/runtime.cpython-312.pyc differ diff --git a/harness/__pycache__/scheduler.cpython-312.pyc b/harness/__pycache__/scheduler.cpython-312.pyc index e4508ee..9e1c9cb 100644 Binary files a/harness/__pycache__/scheduler.cpython-312.pyc and b/harness/__pycache__/scheduler.cpython-312.pyc differ diff --git a/harness/commands.py b/harness/commands.py index 858aaff..4ba93e2 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -8,7 +8,63 @@ import secrets from datetime import datetime, timezone from . import es -from .levels import size_class +from .levels import LEVELS, size_class + +# лестница сверху вниз: min → hour → day (sec эскалации не шлёт — рефлекс без LLM) +_ORDER = ["sec", "min", "hour", "day"] + + +def level_above(level: str): + """Уровень над данным или None (day эскалирует некуда — это уже вершина).""" + if level not in _ORDER: + return None + i = _ORDER.index(level) + return _ORDER[i + 1] if i + 1 < len(_ORDER) else None + + +def raise_escalation(from_level: str, reason: str, context: str = "", severity: str = "info", + task_id: str = None, source: str = "cli"): + """EscalationRaised: важное с нижнего уровня поднимается наверх. + + Проекция кладёт эскалацию в model['escalations'], scheduler inject'ит + открытые в контекст верхних уровней (build_context). Идемпотентно по id. + """ + if from_level not in _ORDER: + raise ValueError(f"неизвестный уровень {from_level}") + to_level = level_above(from_level) + if to_level is None: + raise ValueError(f"у {from_level} нет уровня выше — эскалация некуда") + if severity not in ("info", "warn", "critical"): + raise ValueError(f"неизвестный severity {severity} (info|warn|critical)") + esc_id = f"e-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{secrets.token_hex(3)}" + es.append_event("harness", esc_id, "EscalationRaised", { + "id": esc_id, "from_level": from_level, "to_level": to_level, + "reason": reason, "context": context[:4000], "severity": severity, + "task_id": task_id, "source": source, + }) + return esc_id, to_level + + +def acknowledge_escalation(esc_id: str, level: str = "hour", note: str = ""): + """Закрыть эскалацию (верхний уровень принял к сведению/отработал).""" + model = es.run_projections() + e = (model.get("escalations") or {}).get(esc_id) + if e is None: + raise ValueError(f"эскалация {esc_id} не найдена") + if e.get("status") == "acked": + raise ValueError(f"эскалация {esc_id} уже закрыта") + es.append_event("harness", esc_id, "EscalationAcknowledged", { + "id": esc_id, "level": level, "note": note[:1000], + }) + return esc_id + + +def list_escalations(only_open: bool = False): + model = es.run_projections() + out = [e for e in (model.get("escalations") or {}).values() + if not only_open or e.get("status") == "open"] + out.sort(key=lambda e: e.get("ts", ""), reverse=True) + return out def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "cli"): @@ -77,10 +133,13 @@ def list_tasks(): def status(): model = es.run_projections() + open_escalations = sum(1 for e in (model.get("escalations") or {}).values() + if e.get("status") == "open") return { "cycle_state": model["cycle_state"], "tasks_ready": sum(1 for t in model["tasks"].values() if t["status"] == "ready"), "tasks_done": sum(1 for t in model["tasks"].values() if t["status"] == "done"), "digests": len(model["digests"]), + "escalations_open": open_escalations, "spend": model.get("spend", [])[-8:], } \ No newline at end of file diff --git a/harness/es.py b/harness/es.py index d363571..67feef5 100644 --- a/harness/es.py +++ b/harness/es.py @@ -106,12 +106,20 @@ def get_events(after_id: int = 0, limit: int = 1000): return [dict(r) for r in rows] +def count_events() -> int: + db = _conn() + n = db.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] + db.close() + return n + + # ────────────────────────── ПРОЕКЦИИ ────────────────────────── def _default_model(): return { "tasks": {}, # task_id → {title, size, status, level, session_id, result_ref, ...} "digests": {}, # (level, period) → {summary_ref, text} + "escalations": {}, # esc_id → {from_level, to_level, reason, status: open|acked} "cycle_state": {}, # level → {busy, current_task, last_tick} "metrics": [], # [{level, ticks, absorbed, llm_ticks, tokens, cost}] "agents": {}, # level → {model, calls} @@ -135,6 +143,8 @@ def _apply_event(model: dict, ev: dict): etype, data = ev["event_type"], json.loads(ev["data"]) aid = ev["aggregate_id"] + if not isinstance(data, dict): + data = {} if etype == "TaskSubmitted": model["tasks"][aid] = { "id": aid, "title": data.get("title", ""), "size": data.get("size", "M"), @@ -174,6 +184,20 @@ def _apply_event(model: dict, ev: dict): keep = sorted(model["digests"].items(), key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400] model["digests"] = dict(keep) + elif etype == "EscalationRaised": + model.setdefault("escalations", {})[aid] = { + **data, "status": "open", "ts": ev["timestamp"], + } + elif etype == "EscalationAcknowledged": + e = model.setdefault("escalations", {}).get(aid) + if e: + e["status"] = "acked" + e["acked_at"] = ev["timestamp"] + e["ack_level"] = data.get("level") + else: + model["escalations"][aid] = { + "status": "acked", "ack_level": data.get("level"), "ts": ev["timestamp"], + } elif etype == "CycleTicked": level = data.get("level", "?") cs = model["cycle_state"].setdefault(level, {"busy": False, "current_task": None, "ticks": 0}) @@ -214,6 +238,12 @@ def _apply_event(model: dict, ev: dict): m["ignored"] = True else: model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]} + elif etype == "CycleError": + # счётчик ошибок по уровням — материал для эскалаций (демон/CLI) + cs = model.setdefault("cycle_state", {}).setdefault( + data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0}) + cs["errors"] = cs.get("errors", 0) + 1 + cs["last_error"] = (data.get("error") or "")[:300] # trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага diff --git a/harness/levels.py b/harness/levels.py index c1089a8..1d7aace 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -48,7 +48,11 @@ LEVELS = { "cli.py tg-reply \"текст\" (тон: кратко, по-русски, тёпло; можно " "выполнить просьбу инструментами и ответить результатом). Быстрые " "мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. " - "Если просьба большая — подай её как задачу (cli.py task) и скажи это."), + "Если просьба большая — подай её как задачу (cli.py task) и скажи это. " + "Важное, что не по зубам уровню (падение тика, повторная ошибка, " + "требуется фикс с большим контекстом) — эскалируй наверх: " + "cli.py escalate \"причина\" -c \"контекст\" -s critical|warn|info; " + "эскалацию увидит часовой/дневной уровень в своём контексте."), can_shell=True, ), "hour": CycleLevel( @@ -56,14 +60,20 @@ LEVELS = { model="auto", effort="medium", ctx_tokens=32_000, task_classes=("S", "M"), mandate=("Ты — часовой поток харнеса Simbiot. Дайджест часа: что произошло, " "статусы фоновых задач, батч мелочей (S/M). Консолидируй минутные " - "события в выжимку — сырое наверх не поднимай."), + "события в выжимку — сырое наверх не поднимай. " + "Эскалации от нижних уровней (секция ЭСКАЛАЦИИ в контексте) — твоя " + "зона: разберись и закрой cli.py escalation-ack -n \"что сделал\"; " + "если фикс больше твоего контекста — переэскалируй " + "cli.py escalate \"причина\" -f hour -s critical."), can_shell=True, ), "day": CycleLevel( name="day", label="День · манифест", every=DAY, uses_llm=True, model="auto", effort="high", ctx_tokens=128_000, task_classes=("S", "M", "L"), mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач " - "L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд."), + "L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд. " + "Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, " + "поднятое с нижних уровней; закрой cli.py escalation-ack ."), can_shell=True, ), } diff --git a/harness/scheduler.py b/harness/scheduler.py index dd73f2d..9445f93 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -16,6 +16,8 @@ from datetime import datetime, timezone, timedelta from . import es from .levels import LEVELS from . import runtime +from . import commands +from .commands import _ORDER # интервал демона POLL = 1.0 @@ -65,6 +67,11 @@ def find_task(level_cfg, model: dict): return None +def _lvl_rank(name: str) -> int: + """Ранг уровня в лестнице; неизвестный → ниже всех (эскалации ему не адресованы).""" + return _ORDER.index(name) if name in _ORDER else len(_ORDER) + + def build_context(level_cfg, model: dict, task=None): """Сборка контекста тика: мандат + память уровня + выжимки нижних + задача.""" parts = [level_cfg.mandate] @@ -89,6 +96,27 @@ def build_context(level_cfg, model: dict, task=None): for d in digests[:6]: parts.append(f"- [{d['level']}] {d['text'][:400]}") + # эскалации: открытые, адресованные этому уровню и выше — в приоритете + escs = [] + for e in (model.get("escalations") or {}).values(): + if not isinstance(e, dict) or e.get("status") != "open" or e.get("stub"): + continue + to_level = e.get("to_level", "day") + if to_level not in _ORDER or _lvl_rank(level_cfg.name) < _lvl_rank(to_level): + continue + escs.append(e) + if escs: + escs.sort(key=lambda e: ({'critical': 0, 'warn': 1, 'info': 2}.get(e.get("severity"), 3), + e.get("ts", "")), reverse=False) + lines = [] + for e in escs[:10]: + who = f"от {e.get('from_level')}" if e.get("from_level") else "" + lines.append(f"- [{e['id']}] ({e.get('severity','info')}) {who} → " + f"{e.get('to_level')}: {e.get('reason','')[:300]}" + + (f"\n контекст: {e.get('context','')[:400]}" if e.get("context") else "")) + parts.append("ЭСКАЛАЦИИ (важное с нижних уровней; отработай и закрой: " + "./venv/bin/python cli.py escalation-ack ):\n" + "\n".join(lines)) + # инбокс: входящие TG-сообщения, ждущие ответа (в приоритете) inbox = [m for m in (model.get("inbox") or {}).values() if not m.get("replied") and not m.get("ignored") and m.get("text")] @@ -184,6 +212,20 @@ def process_level(level_cfg, model: dict): "level": level_cfg.name, "error": str(e)[:1000], "trace": traceback.format_exc()[-2000:], }) + # воркер LLM-тика умер — наверх, чтобы верхний уровень узнал сразу: + # нижний сам себя не чинит (контекст мал), фикс идёт сверху. + try: + import secrets as _secrets + esc_id = f"e-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{_secrets.token_hex(3)}" + es.append_event("harness", esc_id, "EscalationRaised", { + "id": esc_id, "from_level": level_cfg.name, + "to_level": commands.level_above(level_cfg.name) or "day", + "reason": f"тик {level_cfg.name} упал: {str(e)[:200]}", + "context": traceback.format_exc()[-1500:], + "severity": "warn", "task_id": None, "source": "scheduler", + }) + except Exception: + pass # эскалация — best effort: не маскировать исходную ошибку return "error: " + str(e)[:120] finally: st["busy"] = False