diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index bb51752..6b3f97a 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -513,12 +513,6 @@ def resolve_tool_budget(level: str) -> int: def main(): payload = json.loads(sys.stdin.read()) - from harness import es - from harness.runtime import setup_provider, _wire_trace_hooks - - setup_provider() - from strands_harness import create_harness - trace_id = payload.get("trace_prefix") or "tick" level = payload.get("level") or "?" # круг доверия тика (harness/trust.py): в env — его наследуют shell и @@ -526,6 +520,22 @@ def main(): from harness.trust import norm as _norm_circle circle = _norm_circle(payload.get("circle")) os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle) + # движок тика (harness/cc_backend.py): claude_code — тик исполняет Claude Code + # со своей моделью на уровень (роутер не нужен); иначе — Strands + 9router + from harness import cc_backend + if cc_backend.backend_for(level) == "claude_code": + try: + _emit(payload, cc_backend.run(payload, _emit_budget_incremental)) + except Exception as e: + import traceback + _emit(payload, {"ok": False, "error": f"claude_code: {e}\n{traceback.format_exc()[-800:]}"}) + return + + from harness import es + from harness.runtime import setup_provider, _wire_trace_hooks + + setup_provider() + from strands_harness import create_harness try: # RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup — # поиск по базе кооператива; лимиты (1 запрос/tick, k<=5, таймаут) diff --git a/harness/cc_backend.py b/harness/cc_backend.py new file mode 100644 index 0000000..922d665 --- /dev/null +++ b/harness/cc_backend.py @@ -0,0 +1,130 @@ +"""Simbiot Harness — движок LLM-тика «claude_code»: тик исполняет Claude Code (`claude -p`). + +Альтернатива Strands + 9router (_llm_worker). Выбор движка и модели — на уровень: + SIMBIOT_LLM_BACKEND_ > SIMBIOT_LLM_BACKEND > strands (strands | claude_code) + SIMBIOT_CC_MODEL_ > дефолт: min — Haiku 5.5, hour — Sonnet 5.5, day — Opus 5.5 + SIMBIOT_CC_API_KEY — ключ Anthropic API для тиков (без него — вход Claude Code на сервере) +Запускается внутри того же изолированного воркера (isolated_runner): таймаут уровня +убивает группу процессов вместе с claude. + +Что сохраняется от Strands-тика: + - мандат уровня → --append-system-prompt; промпт тика → stdin; + - круг доверия: хук cc_guard.py на каждый инструмент (trust.check_tool), MCP портала — + только наш сервер (--strict-mcp-config) с токеном харнеса и лимитом вызовов за тик; + - бюджет шагов — --max-turns (resolve_tool_budget уровня); + - трасса: InvocationStarted здесь, ToolCalled/ToolResult — хук; + - BudgetSpent инкрементально — по каждому ответу модели из stream-json (kill по + таймауту теряет только хвост, не весь тик). +Сессии Claude Code не сохраняются (--no-session-persistence): тики не засоряют панель. +""" +import json +import os +import shutil +import subprocess +import tempfile + +from .levels import _env_cfg + +HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_MODELS = {"min": "claude-haiku-5-5", "hour": "claude-sonnet-5-5", "day": "claude-opus-5-5"} +RAG_HINT = ("\n\nДвижок тика — Claude Code. Поиск по базе знаний кооператива: " + "./venv/bin/python cli.py rag-query \"вопрос целиком\" (1 запрос за тик). " + "Портал кооператива — MCP-инструменты mcp__portal__* (до 4 вызовов за тик). " + "Команды харнеса (tg-reply, task, escalate, …) — ./venv/bin/python cli.py … из корня репо.") + + +def backend_for(level: str) -> str: + v = (_env_cfg(f"SIMBIOT_LLM_BACKEND_{level.upper()}") or _env_cfg("SIMBIOT_LLM_BACKEND") or "strands").strip().lower() + return "claude_code" if v in ("claude_code", "claude-code", "claude", "cc") else "strands" + + +def model_for(level: str) -> str: + return _env_cfg(f"SIMBIOT_CC_MODEL_{level.upper()}") or DEFAULT_MODELS.get(level, DEFAULT_MODELS["min"]) + + +def claude_bin() -> str: + return _env_cfg("SIMBIOT_CLAUDE_BIN") or shutil.which("claude") or "/usr/bin/claude" + + +def _mcp_config(tmpdir: str): + """Только MCP портала с токеном харнеса; без токена — без MCP.""" + from . import portal_mcp + tok = portal_mcp._load_token() + if not tok: + return None + p = os.path.join(tmpdir, "mcp.json") + with open(p, "w") as f: + json.dump({"mcpServers": {"portal": {"type": "http", "url": portal_mcp._url(), + "headers": {"Authorization": f"Bearer {tok}"}}}}, f) + os.chmod(p, 0o600) + return p + + +def _usage_delta(u: dict) -> dict: + p = sum(int(u.get(k) or 0) for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")) + return {"prompt_tokens": p, "completion_tokens": int(u.get("output_tokens") or 0), "model_calls": 1} + + +def run(payload: dict, emit_budget) -> dict: + """Тик на Claude Code. Возвращает {"ok", "text", "usage", "trace_id"} как Strands-воркер.""" + from . import es + from ._llm_worker import resolve_tool_budget + level = payload.get("level") or "min" + trace_id = payload.get("trace_prefix") or "tick" + model = model_for(level) + es.append_event("trace", f"{trace_id}:cc0", "InvocationStarted", + {"trace_id": trace_id, "level": level, "model": model, "engine": "claude_code"}) + tmpdir = tempfile.mkdtemp(prefix="simbiot_cc_") + try: + state = os.path.join(tmpdir, "state.json") + guard = f"{os.path.join(HARNESS_DIR, 'venv', 'bin', 'python')} {os.path.join(HARNESS_DIR, 'harness', 'cc_guard.py')}" + settings = {"hooks": {ev: [{"matcher": "*", "hooks": [{"type": "command", "command": guard, "timeout": 30}]}] + for ev in ("PreToolUse", "PostToolUse")}} + cmd = [claude_bin(), "-p", "--model", model, "--output-format", "stream-json", "--verbose", + "--no-session-persistence", "--max-turns", str(resolve_tool_budget(level)), + "--append-system-prompt", (payload.get("instructions") or "") + RAG_HINT, + "--settings", json.dumps(settings), + # права решает страж (cc_guard → trust.check_tool), не интерактивные промпты + "--dangerously-skip-permissions"] + if not payload.get("can_shell"): + cmd += ["--disallowedTools", "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit"] + mcp = _mcp_config(tmpdir) + cmd += (["--mcp-config", mcp] if mcp else []) + ["--strict-mcp-config"] + env = dict(os.environ, SIMBIOT_CC_STATE=state, SIMBIOT_TRACE_ID=trace_id, SIMBIOT_TICK_LEVEL=level) + # отдельный ключ Anthropic API для харнеса (иначе — вход Claude Code на сервере) + api_key = _env_cfg("SIMBIOT_CC_API_KEY") + if api_key: + env["ANTHROPIC_API_KEY"] = api_key + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=HARNESS_DIR, env=env, text=True) + proc.stdin.write(payload.get("prompt") or "") + proc.stdin.close() + seen, usage, final = set(), {"prompt_tokens": 0, "completion_tokens": 0, "model_calls": 0}, None + for line in proc.stdout: + try: + ev = json.loads(line) + except ValueError: + continue + if ev.get("type") == "assistant": + msg = ev.get("message") or {} + mid = msg.get("id") + if mid and mid not in seen and msg.get("usage"): + seen.add(mid) # один ответ модели = несколько событий по блокам — считаем раз + d = _usage_delta(msg["usage"]) + for k in usage: + usage[k] += d[k] + emit_budget(level, trace_id, {**d, "engine": "claude_code"}, model) + elif ev.get("type") == "result": + final = ev + err = proc.stderr.read()[-500:] + proc.wait() + if not final: + return {"ok": False, "error": f"claude_code: нет результата (rc={proc.returncode}) {err}"} + if final.get("is_error"): + return {"ok": False, "error": f"claude_code: {str(final.get('result') or final.get('subtype'))[:400]}"} + usage["cost_usd"] = final.get("total_cost_usd") + usage["engine"] = "claude_code" + usage["model"] = model + return {"ok": True, "text": final.get("result") or "", "usage": usage, "trace_id": trace_id} + finally: + shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/harness/cc_guard.py b/harness/cc_guard.py new file mode 100644 index 0000000..dc55dd2 --- /dev/null +++ b/harness/cc_guard.py @@ -0,0 +1,113 @@ +"""Simbiot Harness — хук Claude Code для LLM-тика на движке claude_code (cc_backend.py). + +Claude Code зовёт его на каждом PreToolUse/PostToolUse (stdin — JSON события). + PreToolUse: инструмент Claude → инструмент харнеса → trust.check_tool по кругу тика + (SIMBIOT_TICK_CIRCLE); запрет — exit 2 + причина в stderr (Claude видит её + как отказ инструмента). MCP портала — не больше MAX_CALLS_PER_TICK за тик. + PostToolUse: ToolResult в трассу. +Трасса — те же события, что у Strands (_wire_trace_hooks): ToolCalled/ToolResult в +агрегате trace ":", n — общий счётчик тика (файл SIMBIOT_CC_STATE + flock). +Хук не должен валить тик: любая собственная ошибка → разрешить (кроме явного запрета). +""" +import fcntl +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) + +# инструмент Claude Code → (имя для trust.check_tool, ключ пути/команды) +_READ = {"Read", "Glob", "Grep", "LS", "NotebookRead"} +_WRITE = {"Write", "Edit", "MultiEdit", "NotebookEdit"} +_FREE = {"TodoWrite", "TodoRead", "ExitPlanMode", "ToolSearch"} # без внешних эффектов (ToolSearch — подгрузка схем MCP) + + +def _map(tool: str, inp: dict): + if tool == "Bash": + return "shell", {"command": inp.get("command") or ""} + if tool in _READ: + return "read", {"path": inp.get("file_path") or inp.get("path") or inp.get("notebook_path") or ""} + if tool in _WRITE: + return "write", {"path": inp.get("file_path") or inp.get("notebook_path") or ""} + if tool.startswith("mcp__portal__"): + return "portal_mcp", {"tool": tool[len("mcp__portal__"):]} + if tool in _FREE: + return "todo_write", {} + return tool.lower(), inp # WebFetch/WebSearch/Task… — только круг с полным shell + + +def _state(update): + """Атомарно прочитать/изменить состояние тика (счётчики).""" + path = os.environ.get("SIMBIOT_CC_STATE", "") + if not path: + return update({}) + with open(path, "a+") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.seek(0) + try: + st = json.loads(f.read() or "{}") + except ValueError: + st = {} + out = update(st) + f.seek(0) + f.truncate() + f.write(json.dumps(st)) + return out + + +def _trace(kind: str, payload: dict): + trace_id = os.environ.get("SIMBIOT_TRACE_ID", "") + if not trace_id: + return + try: + from harness import es + + def bump(st): + st["n"] = st.get("n", 0) + 1 + return st["n"] + n = _state(bump) + es.append_event("trace", f"{trace_id}:cc{n}", kind, { + "trace_id": trace_id, "level": os.environ.get("SIMBIOT_TICK_LEVEL", ""), + "engine": "claude_code", **payload}) + except Exception: + pass + + +def main(): + try: + ev = json.loads(sys.stdin.read() or "{}") + except ValueError: + return 0 + tool = ev.get("tool_name") or "?" + inp = ev.get("tool_input") or {} + if ev.get("hook_event_name") == "PostToolUse": + res = ev.get("tool_response") + _trace("ToolResult", {"tool": tool, "ok": True, + "result": (res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str))[:4000]}) + return 0 + name, hinp = _map(tool, inp) + ok, why = True, "" + try: + from harness import trust + ok, why = trust.check_tool(name, hinp, os.environ.get("SIMBIOT_TICK_CIRCLE")) + if ok and name == "portal_mcp": + from harness.portal_mcp import MAX_CALLS_PER_TICK + + def take(st): + st["mcp"] = st.get("mcp", 0) + 1 + return st["mcp"] + if _state(take) > MAX_CALLS_PER_TICK: + ok, why = False, f"лимит: {MAX_CALLS_PER_TICK} вызовов портала за тик" + except Exception: + ok, why = True, "" + _trace("ToolCalled", {"tool": tool, "input": json.dumps(inp, ensure_ascii=False, default=str)[:4000], + **({} if ok else {"blocked": why})}) + if not ok: + print(f"Запрещено стражем харнеса: {why}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/levels.py b/harness/levels.py index 55e7f98..884daee 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -16,12 +16,14 @@ YEAR = 366 * DAY # округляем год как 366 дней для про def _env_cfg(key: str, default: str = "") -> str: - """Значение настройки: env сильнее .env харнеса (мини-ридер без circular import).""" + """Значение настройки: env сильнее .env инстанса (paths.env_path: SIMBIOT_HOME/env/.env + или .env репо) — как runtime._env_or_file.""" val = os.environ.get(key, "").strip() if val: return val try: - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") + from . import paths as _paths + env_path = _paths.env_path() with open(env_path, encoding="utf-8") as f: for line in f: if line.startswith(key + "="): diff --git a/tests/test_cc_backend.py b/tests/test_cc_backend.py new file mode 100644 index 0000000..d0e0c5c --- /dev/null +++ b/tests/test_cc_backend.py @@ -0,0 +1,72 @@ +"""Движок тика claude_code: выбор движка/модели по уровню, страж cc_guard по кругам. Без LLM.""" +import json +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) +ok = bad = 0 + + +def eq(c, msg): + global ok, bad + if c: + ok += 1 + else: + bad += 1 + print("FAIL", msg) + + +def with_env(env, fn): + keep = {k: os.environ.get(k) for k in env} + os.environ.update(env) + try: + return fn() + finally: + for k, v in keep.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +from harness import cc_backend as cb # noqa: E402 + +for k in [k for k in os.environ if k.startswith(("SIMBIOT_LLM_BACKEND", "SIMBIOT_CC_MODEL"))]: + os.environ.pop(k) +os.environ["SIMBIOT_HOME"] = tempfile.mkdtemp() # пустой .env инстанса +eq(cb.backend_for("min") == "strands", "default strands") +eq(with_env({"SIMBIOT_LLM_BACKEND": "claude_code"}, lambda: cb.backend_for("hour")) == "claude_code", "global switch") +eq(with_env({"SIMBIOT_LLM_BACKEND": "claude_code", "SIMBIOT_LLM_BACKEND_DAY": "strands"}, + lambda: cb.backend_for("day")) == "strands", "per-level override wins") +eq(cb.model_for("min") == "claude-haiku-5-5" and cb.model_for("hour") == "claude-sonnet-5-5" + and cb.model_for("day") == "claude-opus-5-5", "default models per level") +eq(with_env({"SIMBIOT_CC_MODEL_MIN": "claude-sonnet-5-5"}, lambda: cb.model_for("min")) == "claude-sonnet-5-5", "model override") +d = cb._usage_delta({"input_tokens": 2, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 7}) +eq(d == {"prompt_tokens": 17, "completion_tokens": 7, "model_calls": 1}, "usage delta") + + +def guard(circle, tool, inp, state): + ev = json.dumps({"hook_event_name": "PreToolUse", "tool_name": tool, "tool_input": inp}) + r = subprocess.run([sys.executable, os.path.join(ROOT, "harness", "cc_guard.py")], input=ev, text=True, + capture_output=True, env=dict(os.environ, SIMBIOT_TICK_CIRCLE=str(circle), SIMBIOT_CC_STATE=state)) + return r.returncode + + +st = tempfile.mktemp() +eq(guard(1, "Bash", {"command": "ls /"}, st) == 0, "owner: full shell") +eq(guard(4, "Bash", {"command": "rm -rf /tmp/x"}, st) == 2, "others: arbitrary shell blocked") +eq(guard(4, "Bash", {"command": "./venv/bin/python cli.py tg-reply m-1 -c hi"}, st) == 0, "others: tg-reply allowed") +eq(guard(3, "Write", {"file_path": "/root/x"}, st) == 2, "own: write blocked") +eq(guard(2, "Read", {"file_path": os.path.join(ROOT, ".env")}, st) == 2, "board: secrets blocked") +eq(guard(2, "Read", {"file_path": "docs/ARCHITECTURE.md"}, st) == 0, "board: docs readable") +eq(guard(4, "mcp__portal__list_members", {}, st) == 2, "others: portal registry blocked") +eq(guard(3, "ToolSearch", {"query": "x"}, st) == 0, "ToolSearch is internal") +eq(guard(3, "WebFetch", {"url": "x"}, st) == 2, "own: web blocked") +st2 = tempfile.mktemp() +codes = [guard(1, "mcp__portal__whoami", {}, st2) for _ in range(5)] +eq(codes == [0, 0, 0, 0, 2], f"portal MCP limit 4/tick: {codes}") +print(f"{ok} ok, {bad} fail") +sys.exit(1 if bad else 0)