fix(fabric): use broadcast subject for NATS capabilities discovery

NATS wildcards (node.*.capabilities.get) only work for subscriptions,
not for publish. Switch to a dedicated broadcast subject
(fabric.capabilities.discover) that all NCS instances subscribe to,
enabling proper scatter-gather discovery across nodes.

Made-with: Cursor
This commit is contained in:
Apple
2026-02-27 03:20:13 -08:00
parent a6531507df
commit 90080c632a
28 changed files with 8883 additions and 1459 deletions

View File

@@ -1,16 +1,97 @@
"""
common.py — shared auth helpers для farmOS tools.
farmOS 4.x підтримує тільки OAuth2 Bearer (не Basic Auth).
При наявності FARMOS_TOKEN — використовуємо його як статичний Bearer.
При наявності FARMOS_USER + FARMOS_PASS — отримуємо JWT через OAuth2
password grant і кешуємо його в пам'яті до закінчення TTL.
"""
import os
import json
import time
import threading
import requests
# ── In-memory OAuth2 token cache (process-level, thread-safe) ────────────────
_token_cache: dict = {}
_token_lock = threading.Lock()
def _auth_headers():
token = os.getenv("FARMOS_TOKEN")
_OAUTH_TOKEN_PATH = "/oauth/token"
# Оновлюємо токен за 60 секунд до закінчення, щоб не отримати 401
_TOKEN_REFRESH_BUFFER_S = 60
def _farmos_base_url() -> str:
return os.getenv("FARMOS_BASE_URL", "").strip().rstrip("/")
def _fetch_oauth_token(base_url: str, user: str, pwd: str, client_id: str) -> str | None:
"""
Робить POST /oauth/token з password grant.
Fail-closed: будь-яка помилка → повертає None (без raise).
"""
try:
resp = requests.post(
base_url + _OAUTH_TOKEN_PATH,
data={
"grant_type": "password",
"username": user,
"password": pwd,
"client_id": client_id,
"scope": "",
},
timeout=5,
)
if resp.status_code == 200:
data = resp.json()
access_token = data.get("access_token", "")
expires_in = float(data.get("expires_in", 3600))
if access_token:
with _token_lock:
_token_cache["access_token"] = access_token
_token_cache["expires_at"] = time.monotonic() + expires_in - _TOKEN_REFRESH_BUFFER_S
return access_token
except Exception:
pass
return None
def _get_cached_oauth_token(base_url: str, user: str, pwd: str, client_id: str) -> str | None:
"""
Повертає кешований токен або отримує новий якщо протух / відсутній.
"""
with _token_lock:
cached = _token_cache.get("access_token")
expires_at = _token_cache.get("expires_at", 0.0)
if cached and time.monotonic() < expires_at:
return cached
# Поза lock — робимо мережевий запит
return _fetch_oauth_token(base_url, user, pwd, client_id)
def _auth_headers() -> dict:
"""
Повертає заголовки авторизації для farmOS JSON:API запитів.
Пріоритет:
1. FARMOS_TOKEN (статичний Bearer — для dev/тестів або PAT)
2. FARMOS_USER + FARMOS_PASS → OAuth2 password grant → Bearer JWT
3. Порожній dict (fail-closed: запит піде без auth, farmOS поверне 403/401)
"""
# 1. Статичний токен
token = os.getenv("FARMOS_TOKEN", "").strip()
if token:
return {"Authorization": f"Bearer {token}"}
user = os.getenv("FARMOS_USER")
pwd = os.getenv("FARMOS_PASSWORD")
if user and pwd:
import base64
auth = base64.b64encode(f"{user}:{pwd}".encode()).decode()
return {"Authorization": f"Basic {auth}"}
# 2. OAuth2 password grant
user = os.getenv("FARMOS_USER", "").strip()
pwd = os.getenv("FARMOS_PASS", os.getenv("FARMOS_PASSWORD", "")).strip()
client_id = os.getenv("FARMOS_CLIENT_ID", "farm").strip()
base_url = _farmos_base_url()
if user and pwd and base_url:
jwt = _get_cached_oauth_token(base_url, user, pwd, client_id)
if jwt:
return {"Authorization": f"Bearer {jwt}"}
return {}

View File

@@ -1,11 +1,542 @@
import logging
import os
import re
import time
from urllib.parse import quote as _urlquote
from .audit import audit_tool_call
import requests
from .common import _auth_headers
logger = logging.getLogger(__name__)
# Динамічно читаємо при кожному виклику (не кешуємо на рівні модуля),
# щоб env-зміни без рестарту підхоплювались у тестах.
def _farmos_base_url() -> str:
return os.getenv("FARMOS_BASE_URL", "").strip()
FARMOS_BASE_URL = os.getenv("FARMOS_BASE_URL", "http://localhost:8080")
# ── Shared limits ─────────────────────────────────────────────────────────────
_MAX_LIMIT = 20
_MIN_LIMIT = 1
_OUTPUT_MAX_LINES = 12
# ── whitelist для farmos_read_logs ───────────────────────────────────────────
_VALID_LOG_TYPES = {"activity", "observation", "harvest", "input", "seeding"}
# ── whitelist для farmos_search_assets ───────────────────────────────────────
_VALID_ASSET_TYPES = {
"asset_land", "asset_plant", "asset_equipment",
"asset_structure", "asset_animal",
}
# farmOS 4.x JSON:API URL: /api/asset/<type> і /api/log/<type>
# Наші whitelist ключі типу "asset_land" → "/api/asset/land"
def _asset_type_to_path(asset_type: str) -> str:
"""Конвертує 'asset_land''asset/land' для URL."""
# asset_land → asset/land, asset_plant → asset/plant, etc.
if asset_type.startswith("asset_"):
return "asset/" + asset_type[len("asset_"):]
return asset_type # fallback (не має траплятися)
# ── Нормалізація типу помилки → human-readable мітка ───────────────────────
def _classify_exception(exc: Exception) -> str:
"""Повертає одну з мітель: timeout / dns / ssl / connect / other."""
name = type(exc).__name__
msg = str(exc).lower()
if "timeout" in name.lower() or "timeout" in msg:
return "timeout"
if "name or service not known" in msg or "nodename nor servname" in msg or "dns" in msg:
return "dns"
if "ssl" in msg or "ssl" in name.lower() or "certificate" in msg:
return "ssl"
if "connect" in name.lower() or "connection" in msg:
return "connect"
return "other"
# ── v4.3: farmos_ping — fail-closed CrewAI tool ──────────────────────────────
# Підключається до operations_agent. Ніколи не кидає виняток.
# Не виводить у відповідь URL або токени.
def _make_lc_tool(name: str, description: str, func):
"""
Обгортає plain function у langchain_core.tools.Tool для сумісності
зі старими версіями crewai (які не мають crewai.tools.tool декоратора).
Fallback: повертає ту саму plain function без обгортки.
"""
try:
from langchain_core.tools import Tool as _LCTool
return _LCTool(name=name, description=description, func=func)
except Exception:
pass
try:
from langchain.tools import Tool as _LCTool2 # type: ignore[import]
return _LCTool2(name=name, description=description, func=func)
except Exception:
pass
# Fallback: plain function (для середовищ без crewai/langchain взагалі)
return func
def _farmos_ping_raw(query: str = "") -> str: # noqa: ARG001
return _farmos_ping_impl()
farmos_ping = _make_lc_tool(
name="farmos_ping",
description=(
"Перевіряє доступність і авторизацію farmOS. "
"Повертає стан: доступний / недоступний / не налаштований. "
"Використовуй для швидкої діагностики перед іншими farmOS-операціями."
),
func=_farmos_ping_raw,
)
def _farmos_ping_impl() -> str:
"""
Логіка farmos_ping, незалежна від декоратора.
Fail-closed: будь-яка помилка → зрозумілий рядок.
"""
_t = time.time()
base_url = _farmos_base_url()
if not base_url:
_tlog_farmos("farmos_ping", ok=False, reason="no_base_url", ms=int((time.time() - _t) * 1000))
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
if not _has_auth():
_tlog_farmos("farmos_ping", ok=False, reason="no_auth", ms=int((time.time() - _t) * 1000))
return "FarmOS URL заданий, але немає токена або логіну/паролю."
# HTTP healthcheck
try:
ping_url = base_url.rstrip("/") + "/api"
headers = _auth_headers()
resp = requests.get(ping_url, headers=headers, timeout=3)
ms = int((time.time() - _t) * 1000)
if resp.status_code == 200:
_tlog_farmos("farmos_ping", ok=True, reason="ok", http=True, status=200, ms=ms)
return "FarmOS доступний."
if resp.status_code in (401, 403):
_tlog_farmos("farmos_ping", ok=False, reason="auth_error", http=True, status=resp.status_code, ms=ms)
return f"FarmOS недоступний: помилка авторизації ({resp.status_code})."
_tlog_farmos("farmos_ping", ok=False, reason=f"http_{resp.status_code}", http=True, status=resp.status_code, ms=ms)
return f"FarmOS недоступний: HTTP {resp.status_code}."
except requests.exceptions.Timeout:
ms = int((time.time() - _t) * 1000)
_tlog_farmos("farmos_ping", ok=False, reason="timeout", err="timeout", ms=ms)
return "FarmOS недоступний: timeout (3s)."
except Exception as exc:
ms = int((time.time() - _t) * 1000)
err_kind = _classify_exception(exc)
_tlog_farmos("farmos_ping", ok=False, reason=err_kind, err=err_kind, ms=ms)
if err_kind == "dns":
return "FarmOS недоступний: DNS/host не знайдено."
if err_kind == "ssl":
return "FarmOS недоступний: TLS/SSL помилка."
if err_kind == "connect":
return "FarmOS недоступний: з'єднання відхилено."
return f"FarmOS недоступний: {type(exc).__name__}."
# ── v4.4: farmos_read_logs — read-only logs tool ─────────────────────────────
def _farmos_read_logs_raw(log_type: str = "activity", limit: int = 10) -> str:
return _farmos_read_logs_impl(log_type=log_type, limit=limit)
farmos_read_logs = _make_lc_tool(
name="farmos_read_logs",
description=(
"Читає останні записи farmOS (логи операцій). "
"log_type: activity | observation | harvest | input | seeding. "
"limit: 1..20. Тільки читання. Fail-closed."
),
func=_farmos_read_logs_raw,
)
def _farmos_read_logs_impl(log_type: str = "activity", limit: int = 10) -> str:
"""
Отримує останні N записів farmOS для заданого типу логу.
Fail-closed: будь-яка помилка → зрозумілий рядок.
"""
_t = time.time()
# Валідація вхідних параметрів
if log_type not in _VALID_LOG_TYPES:
valid = ", ".join(sorted(_VALID_LOG_TYPES))
return f"FarmOS: log_type має бути одним з: {valid}."
limit = max(_MIN_LIMIT, min(_MAX_LIMIT, int(limit)))
base_url = _farmos_base_url()
if not base_url:
_tlog_farmos("farmos_read_logs", ok=False, reason="no_base_url",
log_type=log_type, limit=limit, ms=0)
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
if not _has_auth():
_tlog_farmos("farmos_read_logs", ok=False, reason="no_auth",
log_type=log_type, limit=limit, ms=0)
return "FarmOS URL заданий, але немає токена або логіну/паролю."
headers = _auth_headers()
# Пробуємо з sort=-changed, fallback без sort (деякі версії не підтримують)
param_sets = [
{"page[limit]": limit, "sort": "-changed"},
{"page[limit]": limit},
]
url = f"{base_url.rstrip('/')}/api/log/{log_type}"
try:
resp = _try_requests(url, headers, param_sets, timeout=5)
ms = int((time.time() - _t) * 1000)
if resp is None or resp.status_code == 404:
_tlog_farmos("farmos_read_logs", ok=False, reason="not_found", http=True,
status=404, log_type=log_type, limit=limit, ms=ms)
return "FarmOS: endpoint для логів не знайдено (404)."
if resp.status_code in (401, 403):
_tlog_farmos("farmos_read_logs", ok=False, reason="auth_error", http=True,
status=resp.status_code, log_type=log_type, limit=limit, ms=ms)
return f"FarmOS недоступний: помилка авторизації ({resp.status_code})."
if resp.status_code != 200:
_tlog_farmos("farmos_read_logs", ok=False, reason=f"http_{resp.status_code}",
http=True, status=resp.status_code, log_type=log_type, limit=limit, ms=ms)
return f"FarmOS: помилка запиту ({resp.status_code})."
items, parse_err = _parse_jsonapi_list(resp)
if parse_err:
_tlog_farmos("farmos_read_logs", ok=False, reason="parse_error", http=True,
status=200, log_type=log_type, limit=limit, ms=ms)
return parse_err
if not items:
_tlog_farmos("farmos_read_logs", ok=True, reason="empty", http=True,
status=200, log_type=log_type, limit=limit, ms=ms)
return "FarmOS: записів не знайдено."
lines = []
for item in items:
try:
attrs = item.get("attributes", {}) if isinstance(item, dict) else {}
# Patch 2: розширений маппінг полів
name = _extract_name(attrs, fallback=log_type)
date_str = _extract_date(attrs)
# Patch 2: notes може бути dict {value:...} або description
notes_raw = (
attrs.get("notes")
or attrs.get("notes_value")
or attrs.get("description")
or {}
)
if isinstance(notes_raw, dict):
notes_raw = notes_raw.get("value") or ""
# Patch 1: normalize whitespace + word-boundary truncation
notes = _safe_notes(str(notes_raw))
lines.append(f"- {name} | {date_str} | {notes or ''}")
except Exception:
lines.append("- (помилка читання запису)")
if len(lines) > _OUTPUT_MAX_LINES:
lines = lines[:_OUTPUT_MAX_LINES]
_tlog_farmos("farmos_read_logs", ok=True, reason="ok", http=True,
status=200, log_type=log_type, limit=limit, ms=ms)
return "\n".join(lines)
except requests.exceptions.Timeout:
ms = int((time.time() - _t) * 1000)
_tlog_farmos("farmos_read_logs", ok=False, reason="timeout", err="timeout",
log_type=log_type, limit=limit, ms=ms)
return "FarmOS: timeout при отриманні логів (5s)."
except Exception as exc:
ms = int((time.time() - _t) * 1000)
err_kind = _classify_exception(exc)
_tlog_farmos("farmos_read_logs", ok=False, reason=err_kind, err=err_kind,
log_type=log_type, limit=limit, ms=ms)
return "FarmOS: не вдалося отримати логи (внутрішня помилка)."
# ── v4.5: farmos_search_assets — read-only asset search ──────────────────────
def _farmos_search_assets_raw(
asset_type: str = "asset_land",
name_contains: str = "",
limit: int = 10,
) -> str:
return _farmos_search_assets_impl(
asset_type=asset_type,
name_contains=name_contains,
limit=limit,
)
farmos_search_assets = _make_lc_tool(
name="farmos_search_assets",
description=(
"Шукає активи farmOS за типом і необов'язковим підрядком назви. "
"asset_type: asset_land | asset_plant | asset_equipment | asset_structure | asset_animal. "
"Тільки читання. Fail-closed."
),
func=_farmos_search_assets_raw,
)
def _farmos_search_assets_impl(
asset_type: str = "asset_land",
name_contains: str = "",
limit: int = 10,
) -> str:
"""
Пошук активів farmOS через JSON:API. Fail-closed.
"""
_t = time.time()
# Валідація
if asset_type not in _VALID_ASSET_TYPES:
valid = ", ".join(sorted(_VALID_ASSET_TYPES))
return f"FarmOS: asset_type має бути одним з: {valid}."
limit = max(_MIN_LIMIT, min(_MAX_LIMIT, int(limit)))
name_contains = str(name_contains).strip()[:50] # детерміновано обрізаємо
base_url = _farmos_base_url()
if not base_url:
_tlog_farmos("farmos_search_assets", ok=False, reason="no_base_url",
asset_type=asset_type, limit=limit, ms=0)
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
if not _has_auth():
_tlog_farmos("farmos_search_assets", ok=False, reason="no_auth",
asset_type=asset_type, limit=limit, ms=0)
return "FarmOS URL заданий, але немає токена або логіну/паролю."
headers = _auth_headers()
url = f"{base_url.rstrip('/')}/api/{_asset_type_to_path(asset_type)}"
# Параметри: спробуємо з server-side filter, потім без (client-side fallback)
# farmOS 4.x сортує по "name", не "label" (label → 400)
base_params = {"page[limit]": limit, "sort": "name"}
no_sort_params = {"page[limit]": limit}
if name_contains:
param_sets = [
{**base_params, "filter[name][value]": name_contains},
base_params,
no_sort_params,
]
client_filter = True
else:
param_sets = [base_params, no_sort_params]
client_filter = False
try:
resp = _try_requests(url, headers, param_sets, timeout=5)
ms = int((time.time() - _t) * 1000)
if resp is None or resp.status_code == 404:
_tlog_farmos("farmos_search_assets", ok=False, reason="not_found", http=True,
status=404, asset_type=asset_type, limit=limit, ms=ms)
return "FarmOS: endpoint для asset не знайдено (404)."
if resp.status_code in (401, 403):
_tlog_farmos("farmos_search_assets", ok=False, reason="auth_error", http=True,
status=resp.status_code, asset_type=asset_type, limit=limit, ms=ms)
return f"FarmOS: помилка авторизації ({resp.status_code})."
if resp.status_code != 200:
_tlog_farmos("farmos_search_assets", ok=False, reason=f"http_{resp.status_code}",
http=True, status=resp.status_code, asset_type=asset_type, limit=limit, ms=ms)
return f"FarmOS: помилка запиту ({resp.status_code})."
items, parse_err = _parse_jsonapi_list(resp)
if parse_err:
_tlog_farmos("farmos_search_assets", ok=False, reason="parse_error", http=True,
status=200, asset_type=asset_type, limit=limit, ms=ms)
return parse_err
# Client-side substring filter (plain, no regex)
if client_filter and name_contains and items:
needle = name_contains.lower()
items = [
it for it in items
if needle in _extract_label(it).lower()
]
if not items:
_tlog_farmos("farmos_search_assets", ok=True, reason="empty", http=True,
status=200, asset_type=asset_type, limit=limit,
filtered=bool(name_contains), count=0, ms=ms)
return "FarmOS: нічого не знайдено."
lines = []
for item in items:
try:
label = _extract_label(item)
item_type = str(item.get("type", asset_type))
item_id = str(item.get("id", ""))[:8] # тільки перші 8 символів UUID
line = f"- {label} | {item_type} | id={item_id}"
# Patch 1: normalize і обріз рядка до 120 символів
line = re.sub(r"\s+", " ", line).strip()[:120]
lines.append(line)
except Exception:
lines.append("- (помилка читання активу)")
if len(lines) > _OUTPUT_MAX_LINES:
lines = lines[:_OUTPUT_MAX_LINES]
_tlog_farmos("farmos_search_assets", ok=True, reason="ok", http=True,
status=200, asset_type=asset_type, limit=limit,
filtered=bool(name_contains), count=len(lines), ms=ms)
return "\n".join(lines)
except requests.exceptions.Timeout:
ms = int((time.time() - _t) * 1000)
_tlog_farmos("farmos_search_assets", ok=False, reason="timeout", err="timeout",
asset_type=asset_type, limit=limit, ms=ms)
return "FarmOS: timeout (5s)."
except Exception as exc:
ms = int((time.time() - _t) * 1000)
err_kind = _classify_exception(exc)
_tlog_farmos("farmos_search_assets", ok=False, reason=err_kind, err=err_kind,
asset_type=asset_type, limit=limit, ms=ms)
return f"FarmOS: мережна помилка ({err_kind})."
# ── Shared helpers ────────────────────────────────────────────────────────────
def _has_auth() -> bool:
"""Перевіряє наявність будь-якого виду авторизації."""
token = os.getenv("FARMOS_TOKEN", "").strip()
farmos_user = os.getenv("FARMOS_USER", "").strip()
farmos_pass = os.getenv("FARMOS_PASS", os.getenv("FARMOS_PASSWORD", "")).strip()
return bool(token or (farmos_user and farmos_pass))
def _try_requests(url: str, headers: dict, param_sets: list, timeout: float) -> "requests.Response | None":
"""
Пробує серію наборів параметрів. Повертає першу відповідь, яка не 404,
або None якщо всі 404.
"""
resp = None
for params in param_sets:
resp = requests.get(url, headers=headers, params=params, timeout=timeout)
if resp.status_code != 404:
return resp
return resp # остання відповідь (404 або None)
def _parse_jsonapi_list(resp: "requests.Response") -> "tuple[list, str | None]":
"""
Парсить JSON:API list відповідь. Повертає (items, error_string).
Patch 3: перевіряє що data є list; якщо ні → зрозуміла помилка.
"""
try:
data = resp.json()
except Exception:
return [], "FarmOS: не вдалося розібрати відповідь (не валідний JSON)."
if not isinstance(data, dict):
return [], "FarmOS: неочікуваний формат відповіді."
raw = data.get("data")
if raw is None:
return [], None # порожня відповідь — не помилка
if not isinstance(raw, list):
return [], "FarmOS: неочікуваний формат відповіді (data не є списком)."
return raw, None
def _safe_notes(raw: str, max_len: int = 80) -> str:
"""
Patch 1: normalize whitespace (tabs, newlines, multiple spaces → single space),
потім обрізає по межі слова (не посеред UTF-8 символу).
"""
normalized = re.sub(r"\s+", " ", raw).strip()
if len(normalized) <= max_len:
return normalized
truncated = normalized[:max_len]
# Обріз по останньому пробілу — не посеред слова
boundary = truncated.rfind(" ")
if boundary > max_len // 2:
return truncated[:boundary]
return truncated
def _extract_name(attrs: dict, fallback: str = "") -> str:
"""Patch 2: best-effort name з різних полів JSON:API."""
raw = (
attrs.get("name")
or attrs.get("label")
or attrs.get("type")
or fallback
)
return re.sub(r"\s+", " ", str(raw)).strip()
def _extract_label(item: dict) -> str:
"""Отримує label/name активу з JSON:API item."""
attrs = item.get("attributes", {}) if isinstance(item, dict) else {}
raw = (
attrs.get("label")
or attrs.get("name")
or attrs.get("type")
or "(no label)"
)
return re.sub(r"\s+", " ", str(raw)).strip()
def _extract_date(attrs: dict) -> str:
"""Patch 2: best-effort date з timestamp/changed/created."""
from datetime import datetime, timezone
for field in ("timestamp", "changed", "created"):
ts = attrs.get(field)
if ts is None:
continue
if isinstance(ts, (int, float)) and ts > 0:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
if isinstance(ts, str) and ts:
return ts[:10]
return ""
# ── Telemetry helper ──────────────────────────────────────────────────────────
def _tlog_farmos(
event: str,
ok: bool,
reason: str,
status: int = 0,
http: bool = False,
err: str = "",
log_type: str = "",
asset_type: str = "",
limit: int = 0,
filtered: bool = False,
count: int = -1,
ms: int = 0,
) -> None:
"""PII-safe telemetry. Без URL, токенів, user_id."""
try:
extra = f" http={http} status={status}" if http else ""
extra += f" err={err}" if err else ""
extra += f" log_type={log_type}" if log_type else ""
extra += f" asset_type={asset_type}" if asset_type else ""
extra += f" limit={limit}" if limit else ""
extra += f" filtered={filtered}" if filtered else ""
extra += f" count={count}" if count >= 0 else ""
logger.info(
"AGX_STEPAN_METRIC %s ok=%s reason=%s%s ms=%s",
event, ok, reason, extra, ms,
)
audit_tool_call(
f"tool_farmos_read.{event}",
{"reason": reason, "log_type": log_type, "asset_type": asset_type},
{"ok": ok, "status": status},
ok,
ms,
)
except Exception:
pass
def get_asset(asset_id: str):
_t = time.time()
@@ -29,7 +560,7 @@ def search_assets(name_contains: str = "", limit: int = 10):
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
r.raise_for_status()
out = r.json()
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
audit_tool_call("tool_farmos_read.search_assets", {"name_contains": name_contains}, {"ok": True}, True, int((time.time()-_t)*1000))
return out
@@ -40,7 +571,7 @@ def read_logs(log_type: str = "observation", limit: int = 10):
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
r.raise_for_status()
out = r.json()
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
audit_tool_call("tool_farmos_read.read_logs", {"log_type": log_type, "limit": limit}, {"ok": True}, True, int((time.time()-_t)*1000))
return out
@@ -51,5 +582,5 @@ def read_inventory(limit: int = 10):
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
r.raise_for_status()
out = r.json()
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
audit_tool_call("tool_farmos_read.read_inventory", {"limit": limit}, {"ok": True}, True, int((time.time()-_t)*1000))
return out