T042+T043: Solution Scout v0 Hermes plugin (fail-closed, propose-only)

This commit is contained in:
m2 (AI Agent) 2026-07-02 05:01:29 +02:00
parent ab01f39842
commit 6371ddcc75
6 changed files with 858 additions and 0 deletions

68
docs/scout.md Normal file
View file

@ -0,0 +1,68 @@
# Solution Scout v0
Solution Scout is a Hermes plugin that watches safe turn summaries, searches the M2
Marketplace catalog, and shows a non-blocking proposal when a published Solution appears to
cover the current task.
## Install
```bash
./scout/install.sh
```
The installer is an idempotent file drop to:
- `~/.hermes/plugins/m2-solution-scout/plugin.yaml`
- `~/.hermes/plugins/m2-solution-scout/__init__.py`
- `~/.config/m2-market/pull-policy.toml` only when that file does not already exist
It does not enable Scout. To opt in, edit `~/.config/m2-market/pull-policy.toml` and set:
```toml
[scout]
enabled = true
tenant_id = "machine-machine"
desktop_id = "chris-m2o"
operator_id = "op_chris"
```
## Guardrails
Scout fails closed. Missing config, `enabled=false`, or a missing `tenant_id` makes every hook
a no-op. `raw_keystrokes=true` or `raw_client_data=true` refuses session start. The plugin
summarizes and redacts on-box before memory-api egress, searches only `agent_id =
"market:catalog"`, filters hits by `tenant_visibility` containing the desktop tenant or `"*"`,
rate-limits proposals, and never calls `m2-market install`.
## Event Flow
On `on_session_start`, the plugin loads `pull-policy.toml`. On `post_llm_call`, it gates on
policy, rate caps, and cooldowns, then starts a background thread so slow catalog queries do
not block the operator turn. The worker builds an `m2.scout.intent_summary.v1`, posts
`/memory/search` with `agent_id=market:catalog`, applies client-side tenant filtering and
threshold checks, builds an `m2.scout.proposal.v1`, shows `notify-send` with a Store deep
link, and falls back to the Herdr socket toast when desktop notification delivery is
unavailable.
The deep link format is:
```text
m2store://listing/{listing_id}?proposal_id={proposal_id}&source=scout
```
## Telemetry Paths
Local state lives at:
```text
~/.local/share/m2-market/scout/state.json
```
Proposal telemetry is append-only JSONL:
```text
~/.local/share/m2-market/scout/outbox/YYYY-MM-DD.jsonl
```
The v0 plugin records `proposal_shown` locally for later batching to `market:evidence`.
Accept, dismiss, debit, grant, and install remain owned by M2 Store and `m2-market`.

22
scout/install.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_SRC="${SRC_DIR}/plugin/m2-solution-scout"
PLUGIN_DST="${HOME}/.hermes/plugins/m2-solution-scout"
CONFIG_DIR="${HOME}/.config/m2-market"
CONFIG_PATH="${CONFIG_DIR}/pull-policy.toml"
mkdir -p "${PLUGIN_DST}" "${CONFIG_DIR}"
install -m 0644 "${PLUGIN_SRC}/plugin.yaml" "${PLUGIN_DST}/plugin.yaml"
install -m 0644 "${PLUGIN_SRC}/__init__.py" "${PLUGIN_DST}/__init__.py"
if [[ ! -f "${CONFIG_PATH}" ]]; then
install -m 0600 "${SRC_DIR}/pull-policy.example.toml" "${CONFIG_PATH}"
echo "Created disabled config scaffold: ${CONFIG_PATH}"
else
echo "Config already exists, left unchanged: ${CONFIG_PATH}"
fi
echo "Installed Hermes plugin: ${PLUGIN_DST}"
echo "Opt in by editing ${CONFIG_PATH}: set scout.enabled=true and scout.tenant_id to this desktop tenant."

View file

@ -0,0 +1,537 @@
"""Hermes plugin for M2 Solution Scout v0.
The plugin is intentionally fail-closed: no policy opt-in means no hooks do
anything beyond loading local config, and raw-source deny keys refuse startup.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import os
import random
import re
import shutil
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
POLICY_PATH = Path("~/.config/m2-market/pull-policy.toml").expanduser()
STATE_DIR = Path("~/.local/share/m2-market/scout").expanduser()
STATE_PATH = STATE_DIR / "state.json"
OUTBOX_DIR = STATE_DIR / "outbox"
_SECRET_PATTERNS = [
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
re.compile(r"(?i)\b(api[_ -]?key|secret|token)\s*[:=]\s*['\"]?[^'\"\s]{8,}"),
]
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}")
class PolicyError(RuntimeError):
"""Policy explicitly requests an unsafe mode."""
def _utcnow() -> _dt.datetime:
return _dt.datetime.now(_dt.timezone.utc)
def _iso(ts: _dt.datetime | None = None) -> str:
return (ts or _utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _parse_scalar(value: str) -> Any:
value = value.strip()
if not value or value.startswith("#"):
return ""
if "#" in value and not value.lstrip().startswith(("'", '"')):
value = value.split("#", 1)[0].strip()
if value in {"true", "false"}:
return value == "true"
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
return value[1:-1]
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if not inner:
return []
return [_parse_scalar(part.strip()) for part in inner.split(",")]
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
return value
def parse_toml(text: str) -> dict[str, Any]:
"""Tiny TOML subset parser, with optional tomli avoided for Python 3.10 hosts."""
data: dict[str, Any] = {}
current = data
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
current = data
for part in line[1:-1].split("."):
current = current.setdefault(part.strip(), {})
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
current[key.strip()] = _parse_scalar(value)
return data
def _read_toml(path: Path) -> dict[str, Any]:
try:
import tomli # type: ignore
except Exception:
tomli = None
if not path.exists():
return {}
raw = path.read_bytes()
if tomli is not None:
return tomli.loads(raw.decode("utf-8"))
return parse_toml(raw.decode("utf-8"))
def _section(data: dict[str, Any], *names: str) -> dict[str, Any]:
node: Any = data
for name in names:
if not isinstance(node, dict):
return {}
node = node.get(name, {})
return node if isinstance(node, dict) else {}
def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]:
raw = _read_toml(path)
scout = _section(raw, "scout")
sources = _section(raw, "scout", "sources")
summary = _section(raw, "scout", "summary")
catalog = _section(raw, "scout", "catalog")
store = _section(raw, "scout", "store")
telemetry = _section(raw, "scout", "telemetry")
enabled = bool(scout.get("enabled", False))
tenant_id = str(scout.get("tenant_id", "") or "").strip()
if sources.get("raw_keystrokes") is True or sources.get("raw_client_data") is True:
raise PolicyError("Solution Scout refuses raw_keystrokes/raw_client_data policy")
if not enabled or not tenant_id:
return {"enabled": False, "reason": "disabled_or_missing_tenant"}
return {
"enabled": True,
"mode": scout.get("mode", "suggest"),
"desktop_id": str(scout.get("desktop_id", "unknown-desktop")),
"operator_id": str(scout.get("operator_id", "unknown-operator")),
"tenant_id": tenant_id,
"min_score": float(scout.get("min_score", 0.78)),
"min_coverage": float(scout.get("min_coverage", 0.55)),
"max_proposals_per_day": int(scout.get("max_proposals_per_day", 5)),
"max_proposals_per_session": int(scout.get("max_proposals_per_session", 1)),
"dismiss_cooldown_days": int(scout.get("dismiss_cooldown_days", 14)),
"proposal_cooldown_minutes": int(scout.get("proposal_cooldown_minutes", 45)),
"summary": {
"max_source_chars": int(summary.get("max_source_chars", 12000)),
"max_summary_chars": int(summary.get("max_summary_chars", 900)),
"redact_secrets": bool(summary.get("redact_secrets", True)),
"redact_client_identifiers": bool(summary.get("redact_client_identifiers", True)),
"fail_closed_on_secret": bool(summary.get("fail_closed_on_secret", True)),
},
"catalog": {
"memory_api_url": str(catalog.get("memory_api_url", "https://memory.machinemachine.ai")),
"agent_id": str(catalog.get("agent_id", "market:catalog")),
"limit": int(catalog.get("limit", 5)),
"routing_strategy": str(catalog.get("routing_strategy", "standard")),
},
"store": {
"deeplink_scheme": str(store.get("deeplink_scheme", "m2store")),
"fallback_command": str(store.get("fallback_command", "m2-market show --json")),
},
"telemetry": {"enabled": bool(telemetry.get("enabled", True))},
}
def redact(text: str, *, redact_client_identifiers: bool = True) -> tuple[str, dict[str, bool]]:
found_secret = False
redacted = text
for pattern in _SECRET_PATTERNS:
redacted, count = pattern.subn("[REDACTED_SECRET]", redacted)
found_secret = found_secret or count > 0
found_client = False
if redact_client_identifiers:
redacted, count = _EMAIL_RE.subn("[REDACTED_EMAIL]", redacted)
found_client = count > 0
return redacted, {
"secrets_redacted": found_secret,
"client_identifiers_redacted": found_client,
"failed_closed": False,
}
def summarize_turn(user_message: str, assistant_response: str, policy: dict[str, Any]) -> dict[str, Any]:
summary_policy = policy["summary"]
source = (user_message + "\n" + assistant_response)[: summary_policy["max_source_chars"]]
clean, scrub = redact(
source,
redact_client_identifiers=summary_policy.get("redact_client_identifiers", True),
)
sentences = re.split(r"(?<=[.!?])\s+", " ".join(clean.split()))
summary = " ".join(sentences[:3]).strip()
if len(summary) > summary_policy["max_summary_chars"]:
summary = summary[: summary_policy["max_summary_chars"]].rsplit(" ", 1)[0].strip()
terms = []
for word in _WORD_RE.findall(summary.lower()):
if word in {"the", "and", "with", "this", "that", "from", "have", "will", "into"}:
continue
if word not in terms:
terms.append(word)
if len(terms) >= 12:
break
intent_hash = "sha256:" + hashlib.sha256(summary.encode("utf-8")).hexdigest()
return {
"schema_version": "m2.scout.intent_summary.v1",
"summary_id": _id("sum", policy),
"observation_ids": [],
"desktop_id": policy["desktop_id"],
"operator_id": policy["operator_id"],
"tenant_id": policy["tenant_id"],
"session_id": "",
"summary": summary,
"intent_terms": terms,
"negative_terms": [],
"scrub_status": scrub,
"intent_hash": intent_hash,
"created_at": _iso(),
}
def _id(prefix: str, policy: dict[str, Any]) -> str:
stamp = _utcnow().strftime("%Y%m%d")
desktop = re.sub(r"[^A-Za-z0-9]+", "", policy.get("desktop_id", "desktop"))[:12] or "desktop"
return f"{prefix}_{stamp}_{desktop}_{random.randrange(36**4):04x}"
def _load_state(path: Path = STATE_PATH) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"daily": {}, "sessions": {}, "dismissed": {}, "last_proposal_at": None}
def _save_state(state: dict[str, Any], path: Path = STATE_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, sort_keys=True, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
def gate(policy: dict[str, Any], state: dict[str, Any], *, session_id: str = "") -> tuple[bool, str | None]:
if not policy.get("enabled"):
return False, "disabled"
today = _utcnow().date().isoformat()
daily_count = int(state.get("daily", {}).get(today, 0))
if daily_count >= policy["max_proposals_per_day"]:
return False, "rate_capped"
if session_id:
session_count = int(state.get("sessions", {}).get(session_id, 0))
if session_count >= policy["max_proposals_per_session"]:
return False, "session_capped"
last = state.get("last_proposal_at")
if last:
try:
last_ts = _dt.datetime.fromisoformat(str(last).replace("Z", "+00:00"))
cooldown = _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])
if _utcnow() - last_ts < cooldown:
return False, "proposal_cooldown"
except ValueError:
pass
return True, None
def _memory_api_key() -> str:
if os.environ.get("M2_MEMORY_API_KEY"):
return os.environ["M2_MEMORY_API_KEY"]
config = _read_toml(Path("~/.m2-market/config.toml").expanduser())
memory = _section(config, "memory")
for key in ("api_key", "memory_api_key", "token"):
value = memory.get(key) or config.get(key)
if value:
return str(value)
return ""
def _visible(listing: dict[str, Any], tenant_id: str) -> bool:
visibility = listing.get("tenant_visibility") or []
status = listing.get("status", "published")
return status == "published" and ("*" in visibility or tenant_id in visibility)
def search_catalog(summary: dict[str, Any], policy: dict[str, Any]) -> list[dict[str, Any]]:
import httpx
catalog = policy["catalog"]
headers = {}
api_key = _memory_api_key()
if api_key:
headers["X-API-Key"] = api_key
with httpx.Client(base_url=catalog["memory_api_url"], headers=headers, timeout=10.0) as client:
response = client.post(
"/memory/search",
json={
"query": summary["summary"],
"agent_id": catalog["agent_id"],
"routing_strategy": catalog["routing_strategy"],
"limit": max(catalog["limit"] * 3, catalog["limit"]),
},
)
response.raise_for_status()
items = []
for result in response.json().get("results", []):
meta = result.get("metadata") or {}
listing = meta.get("listing") or {}
listing_id = meta.get("listing_id") or listing.get("listing_id") or listing.get("id")
if not listing_id or not _visible(listing, policy["tenant_id"]):
continue
score = float(result.get("score") or 0.0)
coverage = _estimate_coverage(summary, listing)
if score < policy["min_score"] or coverage < policy["min_coverage"]:
continue
items.append(
{
"listing_id": listing_id,
"solution_id": listing.get("solution_id", ""),
"score": score,
"coverage": coverage,
"listing": listing,
}
)
if len(items) >= catalog["limit"]:
break
return items
def _estimate_coverage(summary: dict[str, Any], listing: dict[str, Any]) -> float:
terms = set(summary.get("intent_terms") or [])
haystack = " ".join(
str(listing.get(k, "")) for k in ("name", "summary", "description", "category")
).lower()
keywords = listing.get("keywords") or []
if isinstance(keywords, list):
haystack += " " + " ".join(str(k).lower() for k in keywords)
if not terms:
return 0.0
hits = sum(1 for term in terms if term in haystack)
return round(min(1.0, hits / max(3, len(terms))), 2)
def build_proposal(match: dict[str, Any], summary: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]:
listing = match["listing"]
proposal_id = _id("prp", policy)
match_id = _id("mat", policy)
listing_id = match["listing_id"]
deeplink = (
f"{policy['store']['deeplink_scheme']}://listing/{quote(listing_id)}"
f"?proposal_id={quote(proposal_id)}&source=scout"
)
now = _utcnow()
return {
"schema_version": "m2.scout.proposal.v1",
"proposal_id": proposal_id,
"match_id": match_id,
"desktop_id": policy["desktop_id"],
"operator_id": policy["operator_id"],
"tenant_id": policy["tenant_id"],
"listing": {
"listing_id": listing_id,
"name": listing.get("name", listing_id),
"summary": listing.get("summary", ""),
"price": listing.get("price", {}),
"stats": listing.get("stats", {}),
},
"confidence": match["score"],
"coverage": match["coverage"],
"deeplink": deeplink,
"shown_at": _iso(now),
"expires_at": _iso(now + _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])),
"_telemetry": {
"session_id": summary.get("session_id", ""),
"intent_hash": summary["intent_hash"],
"solution_id": match.get("solution_id", ""),
},
}
def write_proposal_shown(proposal: dict[str, Any], outbox_dir: Path = OUTBOX_DIR) -> dict[str, Any]:
telemetry = proposal.pop("_telemetry", {})
event = {
"schema_version": "m2.scout.telemetry.v1",
"event_id": _id("evt", proposal),
"event_type": "proposal_shown",
"proposal_id": proposal["proposal_id"],
"listing_id": proposal["listing"]["listing_id"],
"solution_id": telemetry.get("solution_id", ""),
"operator_id": proposal["operator_id"],
"desktop_id": proposal["desktop_id"],
"tenant_id": proposal["tenant_id"],
"session_id": telemetry.get("session_id", ""),
"intent_hash": telemetry.get("intent_hash", ""),
"score": proposal["confidence"],
"coverage": proposal["coverage"],
"reason": None,
"created_at": proposal["shown_at"],
"proposal": proposal,
}
outbox_dir.mkdir(parents=True, exist_ok=True)
path = outbox_dir / f"{_utcnow().date().isoformat()}.jsonl"
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(event, sort_keys=True) + "\n")
return event
def notify(proposal: dict[str, Any]) -> None:
listing = proposal["listing"]
price = listing.get("price") or {}
amount = price.get("amount", "?")
currency = price.get("currency", "m2cr")
body = (
f"This looks like {listing['name']}: ~{int(proposal['coverage'] * 100)}% coverage, "
f"{amount} {currency}. Open in M2 Store?"
)
if shutil.which("notify-send"):
try:
subprocess.Popen(
[
"notify-send",
"M2 Solution Scout",
body,
f"--action=open=Open",
f"--action=dismiss=Dismiss",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
_herdr_toast(body, proposal["deeplink"])
def _herdr_toast(message: str, deeplink: str) -> None:
socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip()
pane_id = os.environ.get("HERDR_PANE_ID", "").strip()
if not socket_path or not pane_id:
return
request = {
"id": f"m2-solution-scout:{int(time.time() * 1000)}",
"method": "pane.toast",
"params": {
"pane_id": pane_id,
"source": "m2-solution-scout",
"title": "M2 Solution Scout",
"message": message,
"deeplink": deeplink,
},
}
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode("utf-8"))
client.close()
except Exception:
pass
def _bump_state(
state: dict[str, Any], policy: dict[str, Any], session_id: str, proposal: dict[str, Any]
) -> None:
today = _utcnow().date().isoformat()
state.setdefault("daily", {})[today] = int(state.setdefault("daily", {}).get(today, 0)) + 1
if session_id:
state.setdefault("sessions", {})[session_id] = int(
state.setdefault("sessions", {}).get(session_id, 0)
) + 1
state["last_proposal_at"] = _iso()
state["last_proposal"] = {
"proposal_id": proposal["proposal_id"],
"listing_id": proposal["listing"]["listing_id"],
"cooldown_until": proposal["expires_at"],
}
state.setdefault("proposals", []).append(state["last_proposal"])
_save_state(state)
def _extract_turn(kwargs: dict[str, Any]) -> tuple[str, str, str]:
user_message = str(kwargs.get("user_message") or kwargs.get("prompt") or "")
assistant_response = str(kwargs.get("assistant_response") or kwargs.get("response") or "")
if not user_message:
messages = kwargs.get("messages")
if isinstance(messages, list):
user_message = "\n".join(
str(m.get("content", "")) for m in messages if isinstance(m, dict) and m.get("role") == "user"
)
session_id = str(kwargs.get("session_id") or "")
return user_message, assistant_response, session_id
class ScoutRuntime:
def __init__(self) -> None:
self.policy: dict[str, Any] = {"enabled": False}
self._lock = threading.Lock()
def on_session_start(self, **kwargs: Any) -> None:
del kwargs
self.policy = load_policy()
def post_llm_call(self, **kwargs: Any) -> None:
user_message, assistant_response, session_id = _extract_turn(kwargs)
if not user_message and not assistant_response:
return
state = _load_state()
ok, _reason = gate(self.policy, state, session_id=session_id)
if not ok:
return
worker = threading.Thread(
target=self._process,
args=(user_message, assistant_response, session_id, state),
daemon=True,
)
worker.start()
def _process(
self, user_message: str, assistant_response: str, session_id: str, state: dict[str, Any]
) -> None:
with self._lock:
try:
summary = summarize_turn(user_message, assistant_response, self.policy)
summary["session_id"] = session_id
matches = search_catalog(summary, self.policy)
if not matches:
return
proposal = build_proposal(matches[0], summary, self.policy)
write_proposal_shown(proposal)
notify(proposal)
_bump_state(state, self.policy, session_id, proposal)
except Exception:
return
_RUNTIME = ScoutRuntime()
def register(ctx: Any) -> None:
ctx.register_hook("on_session_start", _RUNTIME.on_session_start)
ctx.register_hook("post_llm_call", _RUNTIME.post_llm_call)

View file

@ -0,0 +1,3 @@
name: m2-solution-scout
version: "0.1"
description: Solution Scout intent watch

View file

@ -0,0 +1,42 @@
[scout]
enabled = false
mode = "suggest"
desktop_id = "chris-m2o"
operator_id = "op_chris"
tenant_id = ""
min_score = 0.78
min_coverage = 0.55
poll_interval_seconds = 90
max_proposals_per_day = 5
max_proposals_per_session = 1
dismiss_cooldown_days = 14
proposal_cooldown_minutes = 45
[scout.sources]
herdr_runs = true
session_summaries = true
window_titles = false
raw_keystrokes = false
raw_client_data = false
[scout.summary]
max_source_chars = 12000
max_summary_chars = 900
redact_secrets = true
redact_client_identifiers = true
fail_closed_on_secret = true
[scout.catalog]
memory_api_url = "https://memory.machinemachine.ai"
agent_id = "market:catalog"
limit = 5
routing_strategy = "standard"
[scout.store]
deeplink_scheme = "m2store"
fallback_command = "m2-market show --json"
[scout.telemetry]
enabled = true
agent_id = "market:evidence"
batch_seconds = 60

186
scout/tests/test_scout.py Normal file
View file

@ -0,0 +1,186 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import httpx
import pytest
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "m2-solution-scout" / "__init__.py"
@pytest.fixture()
def scout(monkeypatch, tmp_path):
spec = importlib.util.spec_from_file_location("m2_solution_scout_test", PLUGIN)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
assert spec.loader is not None
spec.loader.exec_module(module)
monkeypatch.setattr(module, "STATE_PATH", tmp_path / "state.json")
monkeypatch.setattr(module, "STATE_DIR", tmp_path)
monkeypatch.setattr(module, "OUTBOX_DIR", tmp_path / "outbox")
return module
def policy(scout):
return {
"enabled": True,
"desktop_id": "chris-m2o",
"operator_id": "op_chris",
"tenant_id": "machine-machine",
"min_score": 0.78,
"min_coverage": 0.2,
"max_proposals_per_day": 5,
"max_proposals_per_session": 1,
"proposal_cooldown_minutes": 45,
"summary": {
"max_source_chars": 12000,
"max_summary_chars": 120,
"redact_client_identifiers": True,
},
"catalog": {
"memory_api_url": "https://memory.test",
"agent_id": "market:catalog",
"limit": 5,
"routing_strategy": "standard",
},
"store": {"deeplink_scheme": "m2store"},
}
def test_gate_disabled_opted_out_rate_capped_and_cooldown_noop(scout):
enabled = policy(scout)
assert scout.gate({"enabled": False}, {}, session_id="s1") == (False, "disabled")
assert scout.load_policy(Path("/no/such/file")) == {
"enabled": False,
"reason": "disabled_or_missing_tenant",
}
today = scout._utcnow().date().isoformat()
assert scout.gate(
enabled,
{"daily": {today: 5}, "sessions": {}, "last_proposal_at": None},
session_id="s1",
) == (False, "rate_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {"s1": 1}, "last_proposal_at": None},
session_id="s1",
) == (False, "session_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {}, "last_proposal_at": scout._iso()},
session_id="s1",
) == (False, "proposal_cooldown")
def test_policy_refuses_raw_sources(scout, tmp_path):
path = tmp_path / "pull-policy.toml"
path.write_text(
"""
[scout]
enabled = true
tenant_id = "machine-machine"
[scout.sources]
raw_keystrokes = true
raw_client_data = false
""",
encoding="utf-8",
)
with pytest.raises(scout.PolicyError):
scout.load_policy(path)
def test_redaction_and_summary_are_bounded(scout):
text = (
"Use sk-abcdefghijklmnopqrstuvwxyz and API_KEY=supersecretvalue for jane@example.com. "
"Build a competitor research report with sources and PDF output."
)
summary = scout.summarize_turn(text, "Prepare the report.", policy(scout))
assert "sk-" not in summary["summary"]
assert "supersecretvalue" not in summary["summary"]
assert "jane@example.com" not in summary["summary"]
assert "[REDACTED_SECRET]" in summary["summary"]
assert len(summary["summary"]) <= 120
assert summary["schema_version"] == "m2.scout.intent_summary.v1"
def test_proposal_outbox_record_shape_matches_schema(scout, tmp_path):
p = policy(scout)
summary = scout.summarize_turn("competitor research report pdf", "", p)
match = {
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"score": 0.84,
"coverage": 0.72,
"listing": {
"name": "Competitor Scan",
"summary": "Research report package.",
"price": {"amount": 40, "currency": "m2cr", "model": "fixed"},
"stats": {"installs": 12, "rating": 4.6},
},
}
proposal = scout.build_proposal(match, summary, p)
event = scout.write_proposal_shown(proposal, tmp_path / "outbox")
assert event["event_type"] == "proposal_shown"
assert event["proposal"]["schema_version"] == "m2.scout.proposal.v1"
assert event["proposal"]["listing"]["listing_id"] == "lst_competitor-scan"
assert event["proposal"]["deeplink"].startswith("m2store://listing/lst_competitor-scan")
lines = list((tmp_path / "outbox").glob("*.jsonl"))[0].read_text(encoding="utf-8").splitlines()
assert json.loads(lines[0])["proposal_id"] == event["proposal_id"]
def test_mocked_catalog_search_filters_visibility_and_posts_live_shape(scout, monkeypatch):
requests = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(
200,
json={
"results": [
{
"score": 0.91,
"metadata": {
"listing_id": "lst_competitor-scan",
"listing": {
"name": "Competitor Scan",
"summary": "Competitor research report with PDF output",
"keywords": ["competitor", "research", "report"],
"tenant_visibility": ["*"],
"status": "published",
},
},
},
{
"score": 0.99,
"metadata": {
"listing_id": "lst_private",
"listing": {
"name": "Private",
"tenant_visibility": ["other-tenant"],
"status": "published",
},
},
},
],
"routing": {},
},
)
original_init = httpx.Client.__init__
def patched_init(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
original_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
summary = scout.summarize_turn("competitor research report pdf", "", policy(scout))
results = scout.search_catalog(summary, policy(scout))
assert requests[0]["agent_id"] == "market:catalog"
assert requests[0]["query"] == summary["summary"]
assert "tenant_id" not in requests[0]
assert [item["listing_id"] for item in results] == ["lst_competitor-scan"]