Вторая ось к тактам: время — как часто/глубоко думать, доверие — что
можно с этим человеком. Один код + таблица POLICY (уровни × инструменты).
harness/trust.py:
- круг — только по проверенной личности (tg user_id, MCP-агент, портал,
tui), не по тексту; старт: Alex 1, Vlad/Yura 2 (правление); агенты 4,
портал 3, прочие tg 6, чужие 7; менять — только человек
(cli.py trust set вне тика; из тика — отказ и страж, и CLI);
- круг тика = самый недоверенный вход (входящие + задачи батча); задачи
наследуют круг тика (SIMBIOT_TICK_CIRCLE → TaskSubmitted.circle) —
просьба правления не исполняется позже часом с правами владельца;
- страж в воркере (BeforeToolCallEvent.cancel_tool, не промпт): shell
в кругах ≥ 2 — одна команда cli.py из белого списка круга, без ; && |
> $() ` ${; файлы — чтение docs/out/sites/artifacts/входящих, секреты
и запись — нет; portal_mcp — список круга (правлению без казны и
member_create); rag по кругу; отказ → TrustDenied;
- уровни × круги: партнёров/знакомых — только min; батч не смешивает
круги; строка круга в контексте тика;
- задача правления упёрлась в права → владельцу кнопки «Разрешить/Нет»
(только Alex; разрешение = круг 1).
Закрыта дыра: общий SIMBIOT_TG_ALLOWED давал правлению в личке shell
под root наравне с владельцем.
Тест: test_trust 53 PASS (обходы стража, наследование, батчи, одобрение);
фейковые раннеры трёх тестов приняли circle; полный набор — без регрессий.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
No EOL
6.1 KiB
Python
124 lines
No EOL
6.1 KiB
Python
"""Simbiot Harness — изолированный раннер LLM-тика.
|
||
|
||
Strands держит глобальный asyncio-рантайм: несколько create_harness()
|
||
в одном процессе валит его («cannot schedule new futures after shutdown»).
|
||
Решение — каждый LLM-тик в ОТДЕЛЬНОМ сабпроцессе: чистый asyncio-рантайм
|
||
на тик, полная изоляция, ноль утечек. Демон живёт вечно.
|
||
"""
|
||
import json
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
from . import paths as _paths # t-261004113351-2858c1: БД из SIMBIOT_HOME
|
||
_VENV_PY = os.path.join(_HARNESS_DIR, "venv", "bin", "python")
|
||
_WORKER = os.path.join(_HARNESS_DIR, "harness", "_llm_worker.py")
|
||
MARKER = "@@SIMBIOT_RESULT@@"
|
||
|
||
|
||
def run_llm_tick(level_cfg, prompt: str, session_id=None, trace_prefix=None, timeout=None,
|
||
circle=None):
|
||
"""Запускает LLM-тик в сабпроцессе. Возвращает (text, usage, trace_id).
|
||
|
||
События трассы воркер пишет в стор сам (у него есть es).
|
||
Протокол: воркер пишет результат в JSON-ФАЙЛ (путь передан в env);
|
||
stdout/stderr только для лога — библиотечный шум strands не мешает.
|
||
"""
|
||
import tempfile
|
||
result_path = tempfile.mktemp(prefix="simbiot_tick_", suffix=".json", dir="/tmp")
|
||
# BudgetSpent пишет сам воркер — ИНКРЕМЕНТАЛЬНО, после каждого модельного
|
||
# вызова (см. _llm_worker._wire_budget_hooks). Раньше событие писалось
|
||
# здесь, ПОСЛЕ возврата воркера: таймаут убивал процесс до записи, и
|
||
# spend молча терял весь тик (task t-260929170544-adebea).
|
||
payload = {
|
||
"model": level_cfg.model,
|
||
"effort": level_cfg.effort,
|
||
"instructions": level_cfg.mandate,
|
||
"prompt": prompt,
|
||
"session_id": None, # сессии пока выключены: .agent/sessions растёт без очистки
|
||
"trace_prefix": trace_prefix,
|
||
"can_shell": level_cfg.can_shell,
|
||
"ctx_tokens": level_cfg.ctx_tokens,
|
||
"result_path": result_path,
|
||
"level": level_cfg.name, # для записи BudgetSpent самим воркером
|
||
"circle": circle, # круг доверия тика (harness/trust.py) — страж в воркере
|
||
}
|
||
env = dict(os.environ)
|
||
# t-261004113351-2858c1: БД из paths (SIMBIOT_HOME → <home>/db/harness.db);
|
||
# SIMBIOT_HOME и SIMBIOT_DB наследуются воркеру через dict(os.environ).
|
||
env["SIMBIOT_DB"] = env.get("SIMBIOT_DB", _paths.db_path())
|
||
if timeout is None:
|
||
# таймаут уровня: тик может быть длиннее интервала — занятый уровень
|
||
# просто поглощает такты (TickAbsorbed), это норма архитектуры
|
||
timeout = getattr(level_cfg, "tick_timeout", 300)
|
||
timeout = max(timeout, int(level_cfg.every * 0.8))
|
||
# start_new_session: воркер становится лидером СВОЕЙ процесс-группы —
|
||
# добивание на таймауте достаёт всю группу LLM-потомков по proc.pid,
|
||
# без чтения /proc/<pid>/status.
|
||
proc = subprocess.Popen(
|
||
[_VENV_PY, _WORKER],
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
cwd=_HARNESS_DIR,
|
||
env=env,
|
||
start_new_session=True,
|
||
)
|
||
try:
|
||
stdout, stderr = proc.communicate(
|
||
input=json.dumps(payload).encode(), timeout=timeout)
|
||
except subprocess.TimeoutExpired:
|
||
# воркер не уложился в таймаут. subprocess.communicate() бросает
|
||
# TimeoutExpired БЕЗ .pid — subprocess.run не отдавал Popen, и
|
||
# аварийная зачистка падала с AttributeError ('TimeoutExpired'
|
||
# object has no attribute 'pid', e-261003004801) ДО добивания
|
||
# группы: LLM-потомки выживали и писали в стор после смерти тика
|
||
# (race, burn роутера, t-261002090242). С Popen pid есть всегда.
|
||
pgid = proc.pid
|
||
try:
|
||
if pgid > 1:
|
||
os.killpg(pgid, signal.SIGKILL)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
pass
|
||
for _ in range(10): # до 5с на смерть группы, иначе — SIGKILL ещё раз
|
||
try:
|
||
os.killpg(pgid, 0)
|
||
time.sleep(0.5)
|
||
except (ProcessLookupError, PermissionError) as ex:
|
||
break
|
||
else:
|
||
try:
|
||
os.killpg(pgid, signal.SIGKILL)
|
||
except OSError:
|
||
pass
|
||
for stream in (proc.stdout, proc.stderr, proc.stdin):
|
||
try:
|
||
if stream:
|
||
stream.close()
|
||
except OSError:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=5) # reap: без этого — зомби до смерти родителя
|
||
except subprocess.TimeoutExpired:
|
||
pass
|
||
raise
|
||
else:
|
||
out = None
|
||
if os.path.exists(result_path):
|
||
try:
|
||
with open(result_path) as f:
|
||
out = json.load(f)
|
||
except ValueError:
|
||
out = None
|
||
try:
|
||
os.unlink(result_path)
|
||
except OSError:
|
||
pass
|
||
if out is None:
|
||
return None, {"error": f"worker result file missing: stdout={(stdout or b'')[:200]!r} stderr={(stderr or b'').decode(errors='replace')[-300:]}"}, None
|
||
if not out.get("ok"):
|
||
return None, {"error": (out.get("error") or "unknown")[:500]}, None
|
||
return out.get("text", ""), out.get("usage", {}), out.get("trace_id") |