"""Response shaping every tool shares: the conventions INSTRUCTIONS promise. - Times are ISO-8601 UTC with a `Z`, and anything with a "when" also carries `age_s` so the agent never does clock arithmetic. - Every list is bounded: a tool returns `total`, `truncated`, and when cut, a `more` line naming the argument that fetches the rest. Nothing is cut silently. - A time series comes back as a summary plus a downsampled series, never raw rows. - An entity is looked up by id or by name, and a miss raises ValueError naming the tool that lists the valid choices (the server turns it into a readable ToolError). """ from __future__ import annotations import difflib import math from datetime import datetime, timezone from typing import Callable, Iterable, TypeVar from urllib.parse import urlsplit, urlunsplit from steward.core.time_range import RANGES from steward.mcp._context import mcp_app T = TypeVar("T") SERIES_POINTS = 60 # max points in any returned series def db_session(): """A session on the calling Steward's database; use as `async with db_session() as db`.""" return mcp_app().db_sessionmaker() def now_utc() -> datetime: return datetime.now(timezone.utc) def _aware(dt: datetime) -> datetime: return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) def iso(dt: datetime | None) -> str | None: if dt is None: return None return _aware(dt).astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") def age_s(dt: datetime | None, now: datetime | None = None) -> int | None: if dt is None: return None return max(0, int(((now or now_utc()) - _aware(dt)).total_seconds())) def rnd(value, digits: int = 2): return None if value is None else round(float(value), digits) def clamp_limit(limit: int, default: int, maximum: int) -> int: """`limit` <= 0 means the default; anything above `maximum` is capped.""" if not limit or limit <= 0: return default return min(int(limit), maximum) def bounded(items: list, limit: int, *, key: str = "items", more: str = "") -> dict: """`{key: items[:limit], total, truncated}`, plus `more` when something was cut.""" total = len(items) out = {key: items[:limit], "total": total, "truncated": total > limit} if total > limit: out["more"] = f"Showing {limit} of {total}. " + (more or "Pass a larger `limit`.") return out def lookback(range_: str) -> tuple[datetime, str]: """A lookback window as (since, key). Unlike core parse_range, an unknown key is refused rather than quietly read as 24h: the agent should learn its typo.""" key = (range_ or "24h").strip() if key not in RANGES: raise ValueError(f"range {range_!r} is not one of {', '.join(RANGES)}.") return now_utc() - RANGES[key], key def downsample(points: list[tuple[datetime, float]], n: int = SERIES_POINTS) -> list[tuple[datetime, float]]: """Average consecutive points into at most n, each stamped with its first time.""" if len(points) <= n: return points size = math.ceil(len(points) / n) out = [] for i in range(0, len(points), size): chunk = points[i:i + size] out.append((chunk[0][0], sum(v for _, v in chunk) / len(chunk))) return out def summarize(points: list[tuple[datetime, float]], n: int = SERIES_POINTS) -> dict: """min/avg/max/p95/last over oldest-first (time, value) points, plus a ≤n series.""" if not points: return {"points": 0, "series": []} values = [v for _, v in points] ordered = sorted(values) p95 = ordered[max(0, math.ceil(0.95 * len(ordered)) - 1)] return { "points": len(values), "min": rnd(ordered[0]), "avg": rnd(sum(values) / len(values)), "max": rnd(ordered[-1]), "p95": rnd(p95), "last": rnd(values[-1]), "last_at": iso(points[-1][0]), "series": [[iso(t), rnd(v)] for t, v in downsample(points, n)], } def pick(want: str, rows: Iterable[T], *, kind: str, list_tool: str, name: Callable[[T], str] = lambda r: r.name, ident: Callable[[T], str] = lambda r: r.id) -> T: """The row whose id is `want`, else the one whose name matches (case-insensitive).""" rows = list(rows) want = (want or "").strip() if not want: raise ValueError(f"pass a {kind} name or id; {list_tool} lists them.") for r in rows: if ident(r) == want: return r same = [r for r in rows if (name(r) or "").lower() == want.lower()] if len(same) == 1: return same[0] if same: ids = ", ".join(f"{ident(r)}" for r in same) raise ValueError(f"{len(same)} {kind}s are named {want!r}; pass one of these ids instead: {ids}.") close = difflib.get_close_matches(want.lower(), [(name(r) or "").lower() for r in rows], n=3, cutoff=0.5) hint = f" Did you mean {', '.join(repr(c) for c in close)}?" if close else "" raise ValueError(f"no {kind} named or with id {want!r}.{hint} {list_tool} lists them.") def redact_url(url: str) -> str: """Drop a password from a URL's userinfo; the rest stays readable.""" try: parts = urlsplit(url) except ValueError: return url if parts.password is None: return url host = parts.hostname or "" if parts.port: host = f"{host}:{parts.port}" netloc = f"{parts.username}:***@{host}" if parts.username else f"***@{host}" return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))