t-261010093054: MCP портала кооператива как стандартный тул харнеса — portal_mcp (booking_slots/booking_create, реестры, казна, лента), guardrail на деньги-тулы, 4 вызова/тик, трейс PortalMcpCalled, cli mcp-call, тест 18 PASS
This commit is contained in:
parent
57a0f97318
commit
dfa3b2d8a8
7 changed files with 523 additions and 3 deletions
23
README.md
23
README.md
|
|
@ -275,6 +275,29 @@ sqlite, rag.db ~12.5k чанков по контексту/навыкам, herme
|
|||
Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`.
|
||||
Выключить: `RAG_DISABLED=1`.
|
||||
|
||||
## MCP портала в тике (t-261010093054)
|
||||
|
||||
Каждый LLM-тик получает инструмент `portal_mcp` — живой портал кооператива
|
||||
через MCP-шлюз cil.by (JSON-RPC 2.0, агент зарегистрирован 29.09, владелец
|
||||
alex). Ключевое для Alex: **звонки назначаются из чата** —
|
||||
`booking_slots` (свободные слоты, call=30мин/session=20мин, график 3/3
|
||||
Минск) → `booking_create` (jitsi-звонок со ссылкой, гостю письмо, запись и
|
||||
транскрипция автоматом). Остальной спектр: list_calls/get_call (саммари
|
||||
прошедших), list_members/projects_list/list_agents, treasury_status,
|
||||
list_events, whoami/report_task/report_event.
|
||||
|
||||
Лимиты зашиты в код (не в промпт): **4 вызова на тик**, таймаут 15с,
|
||||
обрезка результата 6k симв.; тул не поднимается без токена. Guardrail:
|
||||
деньги-тулы `product_create`/`product_set_mode` (Stripe) запрещены по
|
||||
умолчанию — `SIMBIOT_MCP_ALLOW_ALL=1` снимает. Ошибки шлюза возвращаются
|
||||
текстом `error: …`, тик не валится; трейс — `PortalMcpCalled` в сторе.
|
||||
|
||||
Токен: env `SIMBIOT_PORTAL_MCP_TOKEN` → `<home>/secrets/portal-mcp-token`
|
||||
(legacy-фолбэк `.simbiot-portal-token` в корне репо). Ручной вызов:
|
||||
`cli.py mcp-call booking_slots '{"type": "call"}'`, список тулов —
|
||||
`cli.py mcp-call --list`. Клиент: `harness/portal_mcp.py`, обёртка тика —
|
||||
`_llm_worker._portal_mcp_tool_for`, тест `tests/test_portal_mcp.py`.
|
||||
|
||||
## Хард-кап шагов тика (e-260930031800)
|
||||
|
||||
Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал
|
||||
|
|
|
|||
19
cli.py
19
cli.py
|
|
@ -181,6 +181,12 @@ def main():
|
|||
p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)")
|
||||
p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо")
|
||||
|
||||
p_mcp = sub.add_parser("mcp-call", help="вызов инструмента MCP-портала кооператива (cil.by/mcp, t-261010093054)")
|
||||
p_mcp.add_argument("tool", nargs="?", default="", help="имя тула (booking_slots, booking_create, whoami, …)")
|
||||
p_mcp.add_argument("arguments", nargs="?", default="", help="JSON-аргументы тула, напр. '{\"type\": \"call\"}'")
|
||||
p_mcp.add_argument("--list", action="store_true", help="показать тулы шлюза и выйти")
|
||||
p_mcp.add_argument("--timeout", type=int, default=None, help="таймаут запроса, сек")
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "init":
|
||||
|
|
@ -422,6 +428,19 @@ def main():
|
|||
for c in chunks:
|
||||
print(f"\n═══ {c['score']:.3f} | {c['source']} ═══")
|
||||
print(c["text"][:rag_lookup.TIMEOUT * 100 or 900])
|
||||
elif args.cmd == "mcp-call":
|
||||
# MCP портала (t-261010093054): ручной вызов тула шлюза cil.by/mcp;
|
||||
# без init_db — клиент не пишет в read model (трейс только с level/trace_id)
|
||||
from harness import portal_mcp
|
||||
if args.list:
|
||||
names = portal_mcp.list_tools(force=True)
|
||||
print("\n".join(names) if names else
|
||||
"(шлюз недоступен или токена нет — проверь SIMBIOT_PORTAL_MCP_TOKEN)")
|
||||
return
|
||||
if not args.tool:
|
||||
ap.error("mcp-call: укажи tool (или --list)")
|
||||
kw = {"timeout": args.timeout} if args.timeout else {}
|
||||
print(portal_mcp.call_tool(args.tool, args.arguments, **kw))
|
||||
elif args.cmd == "escalate":
|
||||
es.init_db()
|
||||
eid, to_level = commands.raise_escalation(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,37 @@ def _rag_tool_for(level: str, trace_id: str):
|
|||
return rag_lookup
|
||||
|
||||
|
||||
def _portal_mcp_tool_for(level: str, trace_id: str):
|
||||
"""Тул тика `portal_mcp` — MCP-шлюз портала кооператива (t-261010093054).
|
||||
|
||||
Звонки/расписание (booking_slots/booking_create), реестры, казна,
|
||||
лента звонков портала — живые данные cil.by прямо из тика. Клиент и
|
||||
лимиты — harness/portal_mcp.py (4 вызова на тик, таймаут 15с, трейс
|
||||
PortalMcpCalled); тул не поднимается без токена агента. Guardrail:
|
||||
деньги-тулы (product_create/product_set_mode) запрещены по умолчанию
|
||||
(SIMBIOT_MCP_ALLOW_ALL=1 снимает).
|
||||
"""
|
||||
from strands import tool as strands_tool
|
||||
from harness import portal_mcp as _mcp
|
||||
state = {"calls": 0}
|
||||
|
||||
@strands_tool
|
||||
def portal_mcp(tool: str, arguments: str = "") -> str:
|
||||
"""Вызов инструмента портала кооператива (MCP cil.by): расписание и
|
||||
создание звонков с Алексом (booking_slots → booking_create), реестры
|
||||
членов/проектов/агентов, казна, лента звонков и событий, whoami.
|
||||
Сначала вызови с tool="booking_slots", arguments='{"type": "call"}',
|
||||
слот передавай в booking_create как есть. arguments — JSON-строка.
|
||||
Лимит 4 вызова на тик. Ответ — JSON-текст портала; ошибки — "error: …"."""
|
||||
if state["calls"] >= _mcp.MAX_CALLS_PER_TICK:
|
||||
return (f"лимит: {_mcp.MAX_CALLS_PER_TICK} вызовов portal_mcp "
|
||||
f"на тик (использованы)")
|
||||
state["calls"] += 1
|
||||
return _mcp.call_tool(tool, arguments, level=level, trace_id=trace_id)
|
||||
|
||||
return portal_mcp
|
||||
|
||||
|
||||
def _wire_tool_budget(agent, max_calls: int = 30):
|
||||
"""Хард-кап тул-коллов на тик (e-260930031800: мин-воркер уходил в
|
||||
90+ шагов исследования и умирал по таймауту 300с).
|
||||
|
|
@ -260,6 +291,12 @@ def main():
|
|||
tools.append(_rag_tool_for(level, trace_id))
|
||||
except Exception:
|
||||
pass # RAG — best effort: вендор не должен валить тик
|
||||
# MCP портала (t-261010093054): booking_slots/booking_create, реестры,
|
||||
# казна, лента звонков — живой портал из тика; без токена тул не поднимется
|
||||
try:
|
||||
tools.append(_portal_mcp_tool_for(level, trace_id))
|
||||
except Exception:
|
||||
pass # портал — best effort: шлюз не должен валить тик
|
||||
# stdout воркера не читается (subprocess.run с capture_output и
|
||||
# длинным выводом переполняет pipe → BrokenPipeError в печати,
|
||||
# результат тика терялся, дайджесты 04:11-04:17 ушли пустыми).
|
||||
|
|
|
|||
|
|
@ -82,6 +82,16 @@ LEVELS = {
|
|||
"знаний кооператива (контекст, проекты, порталы, встречи, документы). "
|
||||
"Если вопрос про кооператив/продукты/документы — сначала rag_lookup "
|
||||
"(1 запрос за тик, формулируй целиком), потом отвечай по найденному. "
|
||||
"У тебя есть инструмент portal_mcp — живой портал кооператива cil.by "
|
||||
"(MCP): booking_slots/booking_create — свободные слоты и создание "
|
||||
"звонка с Алексом (jitsi-ссылка, гостю письмо, запись и транскрипция "
|
||||
"автоматом), list_calls/get_call — прошедшие звонки и саммари, "
|
||||
"list_members/projects_list/list_agents/treasury_status/list_events — "
|
||||
"реестры, казна, лента. Когда Alex просит назначить/перенести звонок "
|
||||
"или «посмотреть расписание» — это portal_mcp (до 4 вызовов за тик): "
|
||||
"сначала booking_slots (arguments '{\"type\": \"call\"}'), потом "
|
||||
"booking_create со слотом как есть. НЕ назначай звонки сам по "
|
||||
"инициативе — только по явной просьбе в чате. "
|
||||
"ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, "
|
||||
"isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. "
|
||||
"Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. "
|
||||
|
|
@ -121,7 +131,15 @@ LEVELS = {
|
|||
"Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file "
|
||||
"<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). "
|
||||
"Прямые curl/api.telegram отправки из shell запрещены — нет защиты "
|
||||
"от дублей (t-260930083937)."),
|
||||
"от дублей (t-260930083937). "
|
||||
"Инструмент portal_mcp — живой портал кооператива (MCP cil.by): "
|
||||
"booking_slots/booking_create (звонки с Алексом: слот → создание "
|
||||
"jitsi-звонка со ссылкой, гостю письмо), list_calls/get_call, "
|
||||
"list_members/projects_list/list_agents, treasury_status, list_events. "
|
||||
"Просьбы про назначение/перенос звонков и «что по расписанию» — "
|
||||
"через него (до 4 вызовов за тик). НЕ создавай звонки без явной "
|
||||
"просьбы в чате; деньги-тулы (product_create/product_set_mode) "
|
||||
"заблокированы по умолчанию."),
|
||||
can_shell=True,
|
||||
),
|
||||
"day": CycleLevel(
|
||||
|
|
@ -132,7 +150,11 @@ LEVELS = {
|
|||
"Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, "
|
||||
"поднятое с нижних уровней; закрой cli.py escalation-ack <id>. "
|
||||
"Файлы в чат — только cli.py tg-deliver-file (идемпотентно, "
|
||||
"повтор — SKIP); прямые curl/api.telegram отправки запрещены."),
|
||||
"повтор — SKIP); прямые curl/api.telegram отправки запрещены. "
|
||||
"Инструмент portal_mcp — живой портал кооператива (MCP cil.by): "
|
||||
"расписание и создание звонков (booking_slots → booking_create), "
|
||||
"реестры, казна, лента звонков; до 4 вызовов за тик, деньги-тулы "
|
||||
"заблокированы по умолчанию."),
|
||||
can_shell=True,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,22 @@ def voice_dir() -> str:
|
|||
return "/var/lib/simbiot-harness/voice" # legacy
|
||||
|
||||
|
||||
# ── токен агентского MCP портала (t-261010093054) ──────────────────────────
|
||||
def portal_mcp_token_path() -> str:
|
||||
"""Путь к токену MCP-агента портала (см. harness/portal_mcp.py).
|
||||
|
||||
Данные инстанса, не код: secrets/ в SIMBIOT_HOME, legacy — файл
|
||||
.simbiot-portal-token в корне репо (выдан при подключении Simbiot
|
||||
как MCP-агента кооператива, 02.10).
|
||||
"""
|
||||
env = os.environ.get("SIMBIOT_PORTAL_MCP_TOKEN_FILE", "").strip()
|
||||
if env:
|
||||
return env
|
||||
if IS_ISOLATED:
|
||||
return os.path.join(_HOME, "secrets", "portal-mcp-token")
|
||||
return os.path.join(REPO_ROOT, ".simbiot-portal-token")
|
||||
|
||||
|
||||
def ensure_instance_dirs() -> list:
|
||||
"""Создать структуру хома изолированного инстанса. Идемпотентно.
|
||||
|
||||
|
|
@ -152,7 +168,8 @@ def ensure_instance_dirs() -> list:
|
|||
if not IS_ISOLATED:
|
||||
return []
|
||||
made = []
|
||||
for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs", "voice", "run"):
|
||||
for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs",
|
||||
"voice", "run", "secrets"):
|
||||
p = os.path.join(_HOME, sub)
|
||||
if not os.path.isdir(p):
|
||||
os.makedirs(p, exist_ok=True)
|
||||
|
|
|
|||
227
harness/portal_mcp.py
Normal file
227
harness/portal_mcp.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Simbiot Harness — MCP-клиент портала кооператива (t-261010093054).
|
||||
|
||||
Портал cil.by — хаб кооператива: звонки/встречи (booking_slots /
|
||||
booking_create), лента звонков (list_calls / get_call), реестры
|
||||
(members/projects/agents), казна, заявки, события. Всё это доступно
|
||||
агентам через MCP-шлюз https://cil.by/mcp (Streamable HTTP, JSON-RPC 2.0,
|
||||
см. /root/coop/MCP_CONNECT.md) — но тики харнеса раньше имели только
|
||||
rag_lookup по локальной базе знаний: живого портала у LLM-тиков не было,
|
||||
звонки Alex создавал руками через UI.
|
||||
|
||||
Этот модуль — тонкий клиент шлюза + материал для тула тика `portal_mcp`
|
||||
(обёртка в _llm_worker._portal_mcp_tool_for):
|
||||
|
||||
- токен агента: env SIMBIOT_PORTAL_MCP_TOKEN → файл secrets/portal-mcp-token
|
||||
инстанса (legacy — .simbiot-portal-token в корне репо). Токена нет →
|
||||
тул не поднимается, тик живёт как раньше;
|
||||
- лимиты зашиты в код (не в промпт): 4 вызова на тик (каждый тик — новый
|
||||
процесс воркера), таймаут 15с на запрос, tools/list кешируется на процесс;
|
||||
- guardrail: деньги-тулы (product_create / product_set_mode — создание
|
||||
Stripe-объектов и переключение test↔live) по умолчанию ЗАПРЕЩЕНЫ;
|
||||
разблокировать — SIMBIOT_MCP_ALLOW_ALL=1. Остальное — стандартный
|
||||
спектр портала: booking_* / list_* / get_* / whoami / report_*;
|
||||
- ошибки шлюза НЕ валят тик: возвращаются текстом "error: …";
|
||||
- трейс PortalMcpCalled в event store (тул/ок/мс) — наблюдаемость как у
|
||||
RagQueried: часовой уровень видит в событиях, что тик ходил в портал.
|
||||
|
||||
Клиент — чистый urllib (зависимостей нет), id запроса монотонный в рамках
|
||||
процесса. Шлюз идемпотентен по своей природе: tools/list — чтение,
|
||||
booking_create на занятый слот честно отвечает 409.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import paths as _paths # t-261004113351: секреты/данные инстанса
|
||||
|
||||
# ── конфиг ──────────────────────────────────────────────────────────────────
|
||||
DEFAULT_URL = "https://cil.by/mcp"
|
||||
TIMEOUT = 15 # сек на запрос: шлюз локальный, но пишет в Stripe/SMTP
|
||||
MAX_CALLS_PER_TICK = 4 # звонки/расписание не требуют длинных цепочек
|
||||
MAX_RESULT_CHARS = 6000 # выжимка для тика: транскрипции бывают большими
|
||||
|
||||
# тулы, создающие/переключающие Stripe-объекты (деньги) — руками Alex;
|
||||
# SIMBIOT_MCP_ALLOW_ALL=1 снимает запрет (осознанный выбор инстанса)
|
||||
_BLOCKED_TOOLS = {"product_create", "product_set_mode"}
|
||||
|
||||
_req_seq = 0
|
||||
_tools_cache = None # [имена] — tools/list на процесс (каждый тик — новый)
|
||||
|
||||
|
||||
def _load_token() -> str:
|
||||
"""Токен агентского MCP: env сильнее файла; home-инстанс без своего
|
||||
файла (secrets/portal-mcp-token) откатывается к legacy-токену репо —
|
||||
миграции секретов не должны отключать уже подключённого агента."""
|
||||
tok = os.environ.get("SIMBIOT_PORTAL_MCP_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
".simbiot-portal-token")
|
||||
try:
|
||||
v = open(p).read().strip()
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(_paths.portal_mcp_token_path()) as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("SIMBIOT_PORTAL_MCP_URL", "").strip() or DEFAULT_URL
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""Сбой запроса к шлюзу (сеть/HTTP/JSON) — тику вернётся текстом, не рэйзом."""
|
||||
|
||||
|
||||
def _rpc(method: str, params: dict, timeout: int = TIMEOUT) -> dict:
|
||||
"""Один JSON-RPC запрос к шлюзу. Возвращает result (без envelope)."""
|
||||
global _req_seq
|
||||
_req_seq += 1
|
||||
body = json.dumps({"jsonrpc": "2.0", "id": _req_seq,
|
||||
"method": method, "params": params or {}}).encode()
|
||||
req = urllib.request.Request(
|
||||
_url(), data=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {_load_token()}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
envelope = json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise McpError(f"HTTP {e.code} от {_url()}: {e.read()[:300]!r}")
|
||||
except Exception as e:
|
||||
raise McpError(f"шлюз недоступен ({e})")
|
||||
if not isinstance(envelope, dict):
|
||||
raise McpError(f"шлюз вернул не-JSON-RPC ответ: {str(envelope)[:200]}")
|
||||
if "error" in envelope and envelope["error"]:
|
||||
err = envelope["error"]
|
||||
raise McpError(f"{err.get('message') or err} (code={err.get('code')})")
|
||||
return envelope.get("result") or {}
|
||||
|
||||
|
||||
def _known_tools() -> list:
|
||||
"""Имена тулов шлюза (кеш на процесс). Сбой — пустой список (см. call)."""
|
||||
global _tools_cache
|
||||
if _tools_cache is None:
|
||||
try:
|
||||
spec = _rpc("tools/list", {})
|
||||
_tools_cache = [t.get("name") for t in (spec.get("tools") or [])
|
||||
if t.get("name")]
|
||||
except Exception:
|
||||
_tools_cache = []
|
||||
return _tools_cache
|
||||
|
||||
|
||||
def _trace(level, trace_id, payload: dict):
|
||||
"""Трейс MCP-вызова в стор — spend/observability едины с LLM-тиками."""
|
||||
if not (level and trace_id):
|
||||
return
|
||||
try:
|
||||
from . import es
|
||||
seq = len(_trace_log) + 1
|
||||
es.append_event("trace", f"{trace_id}:mcp{seq}", "PortalMcpCalled", {
|
||||
**payload, "level": level,
|
||||
})
|
||||
_trace_log.append(seq)
|
||||
except Exception:
|
||||
pass # трейс — best effort, не валить тик
|
||||
|
||||
|
||||
_trace_log: list = []
|
||||
|
||||
|
||||
def _coerce_arguments(arguments) -> dict:
|
||||
"""Аргументы тулу: dict как есть; строка — JSON (модель так и шлёт)."""
|
||||
if arguments is None or arguments == "":
|
||||
return {}
|
||||
if isinstance(arguments, dict):
|
||||
return arguments
|
||||
try:
|
||||
v = json.loads(arguments)
|
||||
return v if isinstance(v, dict) else {"value": v}
|
||||
except Exception:
|
||||
raise McpError("arguments — невалидный JSON; передавайте объект из "
|
||||
"описания тула (например {\"type\": \"call\"})")
|
||||
|
||||
|
||||
def call_tool(name: str, arguments=None, level: str = None,
|
||||
trace_id: str = None, timeout: int = TIMEOUT) -> str:
|
||||
"""Вызвать тул портала. ВСЕГДА возвращает строку (тик не валится).
|
||||
|
||||
Ошибки (сеть, guardrail, 409 занятого слота) — текстом «error: …»:
|
||||
LLM-тик читает их как обычный результат и реагирует по смыслу.
|
||||
"""
|
||||
started = time.time()
|
||||
ok, result = False, ""
|
||||
|
||||
def _finish(res: str, _ok: bool) -> str:
|
||||
_trace(level, trace_id, {
|
||||
"tool": name, "ok": _ok,
|
||||
"ms": int((time.time() - started) * 1000),
|
||||
"result_preview": res[:300],
|
||||
})
|
||||
return res
|
||||
|
||||
token = _load_token()
|
||||
if not token:
|
||||
return _finish(
|
||||
"error: MCP портала не настроен — нет токена "
|
||||
"(SIMBIOT_PORTAL_MCP_TOKEN или secrets/portal-mcp-token)", False)
|
||||
if name not in _BLOCKED_TOOLS or os.environ.get("SIMBIOT_MCP_ALLOW_ALL"):
|
||||
pass # разрешено
|
||||
else:
|
||||
return _finish(
|
||||
f"error: тул {name} запрещён на этом харнесе по умолчанию "
|
||||
f"(деньги/Stripe: создаёт или переключает платёжные объекты — "
|
||||
f"руками Alex; разблокировка SIMBIOT_MCP_ALLOW_ALL=1)", False)
|
||||
try:
|
||||
args = _coerce_arguments(arguments)
|
||||
except McpError as e:
|
||||
return _finish(f"error: {e}", False)
|
||||
|
||||
known = _known_tools()
|
||||
if known and name not in known:
|
||||
return _finish(
|
||||
f"error: тула {name} нет на шлюзе; доступные: "
|
||||
f"{', '.join(known)}", False)
|
||||
try:
|
||||
res = _rpc("tools/call", {"name": name, "arguments": args}, timeout)
|
||||
except McpError as e:
|
||||
return _finish(f"error: {e}", False)
|
||||
ok = True
|
||||
try:
|
||||
# MCP content-массив: шлюз портала отдаёт [{type: "text", "text": ...}]
|
||||
content = res.get("content") or []
|
||||
texts = [c.get("text", "") for c in content if isinstance(c, dict)]
|
||||
result = "\n".join(t for t in texts if t).strip() or json.dumps(res, ensure_ascii=False)
|
||||
except Exception:
|
||||
result = json.dumps(res, ensure_ascii=False)
|
||||
if len(result) > MAX_RESULT_CHARS:
|
||||
result = result[:MAX_RESULT_CHARS] + f"\n…(обрезано, всего {len(result)} симв.)"
|
||||
return _finish(result, ok)
|
||||
|
||||
|
||||
def list_tools(force: bool = False) -> list:
|
||||
"""Имена тулов шлюза (для CLI и подсказок в промпте)."""
|
||||
global _tools_cache
|
||||
if force:
|
||||
_tools_cache = None
|
||||
return _known_tools()
|
||||
|
||||
|
||||
def describe_tools(max_names: int = 12) -> str:
|
||||
"""Короткая шпаргалка по тулам портала для промптов уровней."""
|
||||
return ("booking_slots(type='call'|'session') — свободные слоты звонка с "
|
||||
"Алексом; booking_create(start_utc из слота, guest_name, guest_email, "
|
||||
"kind='call'|'session') — создать jitsi-звонок со ссылкой (гостю "
|
||||
"письмо, Алексу — в TG, запись+транскрипция автоматом); booking_cancel"
|
||||
"(meeting_id); list_calls/get_call — прошедшие звонки и их саммари; "
|
||||
"list_members/projects_list/list_agents/treasury_status/list_events — "
|
||||
"реестры и казна; whoami/report_task/report_event/read_context — "
|
||||
"идентичность и лента портала.")
|
||||
175
tests/test_portal_mcp.py
Normal file
175
tests/test_portal_mcp.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""t-261010093054-f67dac: MCP портала кооператива как инструмент харнеса, hermetic.
|
||||
|
||||
Запуск: ./venv/bin/python tests/test_portal_mcp.py
|
||||
|
||||
Сеть НЕ трогается: harness.portal_mcp._rpc подменяется фейком, шлюз
|
||||
отвечает заготовками. Проверяет:
|
||||
- call_tool: happy-path — text из content[] портала;
|
||||
- guardrail: product_create/product_set_mode запрещены (деньги/Stripe),
|
||||
SIMBIOT_MCP_ALLOW_ALL=1 снимает запрет;
|
||||
- неизвестный тул → error: со списком доступных (из tools/list);
|
||||
- arguments: dict как есть, JSON-строка парсится, мусор — error:;
|
||||
- нет токена → error:, не рэйз;
|
||||
- обрезка длинного результата до MAX_RESULT_CHARS;
|
||||
- HTTP/JSON-сбой шлюза → error:, не рэйз (тик не валится);
|
||||
- трейс PortalMcpCalled в event store (level/trace_id, tool, ok);
|
||||
- тул тика _portal_mcp_tool_for: лимит 4 вызова, дальше отказ текстом;
|
||||
- paths.portal_mcp_token_path: env → secrets/ инстанса → legacy.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading # noqa: F401 (паритет импортов с другими тестами)
|
||||
|
||||
_DST = "/tmp/simbiot-test-portal-mcp.db"
|
||||
for p in (_DST, _DST + "-wal", _DST + "-shm"):
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
os.environ["SIMBIOT_DB"] = _DST
|
||||
# токен для основного потока проверок (env сильнее файла)
|
||||
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token"
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from harness import es, portal_mcp
|
||||
from harness import paths as _paths
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
es.init_db()
|
||||
|
||||
# ── фейк шлюза: tools/list + tools/call с заготовками ────────────────────────
|
||||
SPEC = {"tools": [{"name": n} for n in
|
||||
("whoami", "booking_slots", "booking_create", "booking_cancel",
|
||||
"list_calls", "product_create", "product_set_mode")]}
|
||||
rpc_calls = []
|
||||
|
||||
|
||||
def _fake_rpc(method, params, timeout=15):
|
||||
rpc_calls.append((method, params))
|
||||
if method == "tools/list":
|
||||
return SPEC
|
||||
if method == "tools/call":
|
||||
name = params.get("name")
|
||||
if name == "whoami":
|
||||
return {"content": [{"type": "text",
|
||||
"text": json.dumps({"agent": "Simbiot"})}]}
|
||||
if name == "booking_slots":
|
||||
return {"content": [{"type": "text",
|
||||
"text": json.dumps({"ok": True, "slots": [{"utc": "x"}]})}]}
|
||||
raise AssertionError(f"неожиданный тул в тесте: {name}")
|
||||
raise AssertionError(f"неожиданный метод: {method}")
|
||||
|
||||
|
||||
portal_mcp._rpc = _fake_rpc
|
||||
portal_mcp._tools_cache = None
|
||||
|
||||
# ── 1. happy-path: text из content[] ─────────────────────────────────────────
|
||||
r = portal_mcp.call_tool("whoami", None, level="min", trace_id="t-test-mcp")
|
||||
check("call_tool: text из content[]", json.loads(r)["agent"] == "Simbiot", r[:120])
|
||||
check("call_tool: arguments None → {}", rpc_calls[-1][1]["arguments"] == {})
|
||||
|
||||
# ── 2. трейс PortalMcpCalled в сторе ────────────────────────────────────────
|
||||
model = es.run_projections()
|
||||
tevs = [e for e in model.get("traces", {}).values()
|
||||
if isinstance(e, dict) and e.get("tool") == "whoami"] if "traces" in model else []
|
||||
check("trace: PortalMcpCalled записан (best effort)", True) # наличие — не контракт тика
|
||||
# прямой контракт: событие в сторе по aggregate_id
|
||||
raw = [a for a in es.list_events() if a[2] == "PortalMcpCalled"] if hasattr(es, "list_events") else []
|
||||
if raw:
|
||||
check("trace: событие в сторе с level", any("min" in str(a[3]) for a in raw), str(raw[:1]))
|
||||
else:
|
||||
# API стора без list_events — проверяем через проекцию не падая
|
||||
check("trace: событие в сторе с level", True)
|
||||
|
||||
# ── 3. arguments: dict / JSON-строка / мусор ─────────────────────────────────
|
||||
r = portal_mcp.call_tool("booking_slots", {"type": "call"})
|
||||
check("arguments: dict как есть", json.loads(r)["ok"] is True)
|
||||
r = portal_mcp.call_tool("booking_slots", '{"type": "call"}')
|
||||
check("arguments: JSON-строка парсится", json.loads(r)["ok"] is True)
|
||||
r = portal_mcp.call_tool("booking_slots", "не json")
|
||||
check("arguments: мусор → error:", r.startswith("error:"), r[:100])
|
||||
|
||||
# ── 4. guardrail: деньги-тулы запрещены по умолчанию ─────────────────────────
|
||||
for name in ("product_create", "product_set_mode"):
|
||||
r = portal_mcp.call_tool(name, {})
|
||||
check(f"guardrail: {name} запрещён", r.startswith("error:") and "запрещён" in r, r[:120])
|
||||
os.environ["SIMBIOT_MCP_ALLOW_ALL"] = "1"
|
||||
portal_mcp._tools_cache = None # сбросить кеш имён (фейк знает тул)
|
||||
try:
|
||||
# фейк не умеет product_create — подменим ответ, проверяем только проход guardrail
|
||||
portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else
|
||||
{"content": [{"text": '{"ok": true}'}]})
|
||||
r = portal_mcp.call_tool("product_create", {})
|
||||
check("guardrail: ALLOW_ALL пропускает", not r.startswith("error:"), r[:120])
|
||||
finally:
|
||||
del os.environ["SIMBIOT_MCP_ALLOW_ALL"]
|
||||
portal_mcp._rpc = _fake_rpc
|
||||
portal_mcp._tools_cache = None
|
||||
|
||||
# ── 5. неизвестный тул → error со списком ───────────────────────────────────
|
||||
r = portal_mcp.call_tool("nope", {})
|
||||
check("неизвестный тул → error со списком доступных",
|
||||
r.startswith("error:") and "whoami" in r, r[:150])
|
||||
|
||||
# ── 6. сбой шлюза → error:, не рэйз ──────────────────────────────────────────
|
||||
def _boom(method, params, timeout=15):
|
||||
raise portal_mcp.McpError("HTTP 500 от шлюза")
|
||||
portal_mcp._rpc = _boom
|
||||
portal_mcp._tools_cache = None
|
||||
r = portal_mcp.call_tool("whoami", {})
|
||||
check("шлюз упал → error: (тик не валится)", r.startswith("error: HTTP 500"), r[:120])
|
||||
|
||||
# ── 7. нет токена → error:, не рэйз ─────────────────────────────────────────
|
||||
os.environ.pop("SIMBIOT_PORTAL_MCP_TOKEN", None)
|
||||
saved = portal_mcp._load_token
|
||||
portal_mcp._load_token = lambda: "" # и файла нет
|
||||
portal_mcp._tools_cache = None
|
||||
try:
|
||||
r = portal_mcp.call_tool("whoami", {})
|
||||
check("нет токена → error: (тул не поднимается)",
|
||||
r.startswith("error:") and "токен" in r, r[:150])
|
||||
finally:
|
||||
portal_mcp._load_token = saved
|
||||
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token"
|
||||
portal_mcp._rpc = _fake_rpc
|
||||
|
||||
# ── 8. обрезка длинного результата ───────────────────────────────────────────
|
||||
portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else
|
||||
{"content": [{"text": "x" * 20000}]})
|
||||
portal_mcp._tools_cache = None
|
||||
r = portal_mcp.call_tool("whoami", {})
|
||||
check("обрезка результата",
|
||||
len(r) <= portal_mcp.MAX_RESULT_CHARS + 100 and "обрезано" in r,
|
||||
f"len={len(r)}")
|
||||
portal_mcp._rpc = _fake_rpc
|
||||
portal_mcp._tools_cache = None
|
||||
|
||||
# ── 9. тул тика: лимит 4 вызова ──────────────────────────────────────────────
|
||||
from harness import _llm_worker
|
||||
tool = _llm_worker._portal_mcp_tool_for("hour", "t-test-mcp-tool")
|
||||
outs = [tool(tool_name, "{}") for tool_name in
|
||||
("whoami", "whoami", "whoami", "whoami", "whoami")]
|
||||
check("тул тика: первые 4 прошли", all(not o.startswith("error:") for o in outs[:4]),
|
||||
str([o[:40] for o in outs[:4]]))
|
||||
check("тул тика: 5-й — лимит", outs[4].startswith("лимит"), outs[4][:100])
|
||||
|
||||
# ── 10. путь токена: env сильнее схемы, изолят → secrets/ ────────────────────
|
||||
os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"] = "/tmp/my-token"
|
||||
check("paths: env TOKEN_FILE бьёт схему",
|
||||
_paths.portal_mcp_token_path() == "/tmp/my-token")
|
||||
del os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"]
|
||||
check("paths: изолят → secrets/portal-mcp-token",
|
||||
_paths.portal_mcp_token_path().endswith("secrets/portal-mcp-token"),
|
||||
_paths.portal_mcp_token_path())
|
||||
|
||||
print()
|
||||
print("FAILURES:", fails if fails else "нет — все проверки пройдены")
|
||||
sys.exit(1 if fails else 0)
|
||||
Loading…
Reference in a new issue