From eaa539552c4c2497428941792c1e5bbce59aff7d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Sat, 18 Jul 2026 13:55:09 +1000 Subject: [PATCH 01/36] feat(sync): HSP/1 personal skill sync client (Milestone 1, client strand) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the hermes-agent HSP/1 sync CLIENT against the frozen wire contract (~/src/specs/collective-wisdom/hsp-1-contract.md §8), tested against an in-process mock HSP server. tools/skills_sync_client.py (new, low-level; does NOT import the CLI): * Full 64-hex sha256 content addressing + canonical JSON (§2.1/§2.5, OI-5) — kept distinct from the truncated local content_hash namespace. * HSPClient: capabilities/refs/objects GET, batch object upload (multipart, raw bytes per §1/§4.2), CAS ref (§4.4) with 409->HSPConflict. * Object building: skill dir -> blob/tree/commit; exec-bit preserved, symlinks skipped, oversize (413) surfaced; profile-root category trees. * push/pull + three-way merge (M1-C): reuses the origin/user/incoming decision semantics of skills_sync.py; non-overlap -> merge commit + retry CAS; true overlap -> refs/user//conflict/ + surface. * DEV-PHASE gate: sync is INERT unless the resolved Nous token carries tool_gateway_admin===true (decoded from the bearer; server re-verifies). * Auth reuses resolve_nous_runtime_credentials() (no refresh reimpl). * maybe_push_skills / maybe_pull_skills gate-and-swallow entrypoints. Opt-in (M1-D): tools/skill_usage.set_sync / is_sync_enabled — a `sync` flag on the .usage.json sidecar; nothing syncs unless opted in. Only agent-created/user-authored skills are eligible (bundled/hub excluded). Hooks: * Debounced push in skill_manage success block (after the write gate). * Periodic pull at the two curator tick sites (gateway housekeeping loop + CLI startup). CLI: hermes sync status|pull|push|now|enable|disable (hermes_cli/subcommands/sync.py + cmd_sync in main.py). Tests: tests/tools/test_skills_sync_client.py — 29 tests (addressing, canonicalization, dev gate, opt-in, object building, merge decisions, and e2e push/pull/idempotency/conflict against a stdlib mock HSP server). --- cli.py | 10 + gateway/run.py | 9 + hermes_cli/main.py | 99 ++ hermes_cli/subcommands/sync.py | 44 + tests/tools/test_skills_sync_client.py | 582 ++++++++++++ tools/skill_manager_tool.py | 62 ++ tools/skill_usage.py | 20 + tools/skills_sync_client.py | 1150 ++++++++++++++++++++++++ 8 files changed, 1976 insertions(+) create mode 100644 hermes_cli/subcommands/sync.py create mode 100644 tests/tools/test_skills_sync_client.py create mode 100644 tools/skills_sync_client.py diff --git a/cli.py b/cli.py index 6cb6345f4be4e..2e10fcd24a3d1 100644 --- a/cli.py +++ b/cli.py @@ -13518,6 +13518,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) except Exception: pass + + # HSP skill sync — best-effort periodic pull, piggy-backing on the + # curator tick. Inert unless the DEV-PHASE gate is open + # (tool_gateway_admin) and a sync base URL is configured; swallows all + # errors so it never blocks CLI startup. + try: + from tools.skills_sync_client import maybe_pull_skills + maybe_pull_skills() + except Exception: + pass if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) self._console_print( diff --git a/gateway/run.py b/gateway/run.py index 61fcd96eb709b..a9c6dce8b4cca 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -21145,6 +21145,15 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop except Exception as e: logger.debug("Curator tick error: %s", e) + # HSP skill sync — best-effort periodic pull on the same cadence. + # Inert unless the DEV-PHASE gate is open (tool_gateway_admin) and + # a sync base URL is configured; never raises. + try: + from tools.skills_sync_client import maybe_pull_skills + maybe_pull_skills() + except Exception as e: + logger.debug("Sync pull tick error: %s", e) + stop_event.wait(timeout=interval) logger.info("Gateway housekeeping stopped") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1525041c111ac..91d77519d789d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -282,6 +282,7 @@ from typing import Optional from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_hooks_flag from hermes_cli.subcommands.cron import build_cron_parser +from hermes_cli.subcommands.sync import build_sync_parser from hermes_cli.subcommands.gateway import build_gateway_parser from hermes_cli.subcommands.profile import build_profile_parser from hermes_cli.subcommands.model import build_model_parser @@ -4304,6 +4305,103 @@ def cmd_cron(args): cron_command(args) +def cmd_sync(args): + """HSP/1 personal skill sync management (status/pull/push/now/enable/disable).""" + import json as _json + + sub = getattr(args, "sync_command", None) + + if sub in {None, ""}: + print( + "usage: hermes sync \n" + "\n" + " status Show sync gate, opt-in, and head state\n" + " pull Pull the owner's HEAD, materialize opted-in skills\n" + " push Push opted-in skills to the owner's HEAD\n" + " now Reconcile now: pull then push\n" + " enable Opt a skill into sync (M1-D opt-in)\n" + " disable Opt a skill out of sync", + file=sys.stderr, + ) + return 1 + + if sub in {"enable", "disable"}: + from tools.skill_usage import set_sync, is_curation_eligible + + skill = args.skill + if not is_curation_eligible(skill): + print( + f"'{skill}' is not sync-eligible (bundled, hub-installed, " + f"external, or not found). Only agent-created / user-authored " + f"skills under ~/.hermes/skills/ can sync.", + file=sys.stderr, + ) + return 1 + set_sync(skill, sub == "enable") + print(f"sync {'enabled' if sub == 'enable' else 'disabled'} for '{skill}'.") + return 0 + + from tools import skills_sync_client as ssc + + if sub == "status": + status = ssc.sync_status() + print(_json.dumps(status, indent=2, ensure_ascii=False)) + if not status.get("logged_in"): + print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr) + elif not status.get("dev_gate_ok"): + print( + "\nDEV-PHASE gate closed: your token lacks 'tool_gateway_admin'. " + "Sync is inert during the dev rollout.", + file=sys.stderr, + ) + elif not status.get("base_url"): + print( + "\nNo sync base URL configured (config.yaml sync.base_url or " + "HERMES_SYNC_BASE_URL). Sync is inert.", + file=sys.stderr, + ) + return 0 + + # pull / push / now — enforce the gate up front with a clear message. + try: + identity = ssc.resolve_identity() + except ssc.SyncInertError as e: + print(f"sync inert: {e}", file=sys.stderr) + return 1 + if not identity.get("dev_gate_ok"): + print( + "sync inert: DEV-PHASE gate closed (token lacks 'tool_gateway_admin').", + file=sys.stderr, + ) + return 1 + if not ssc.resolve_sync_base_url(): + print( + "sync inert: no sync base URL configured (config.yaml sync.base_url " + "or HERMES_SYNC_BASE_URL).", + file=sys.stderr, + ) + return 1 + + try: + if sub == "pull": + result = ssc.pull_skills(identity=identity) + elif sub == "push": + result = ssc.push_skills(identity=identity, message="hermes sync push") + elif sub == "now": + pull_res = ssc.pull_skills(identity=identity) + push_res = ssc.push_skills(identity=identity, message="hermes sync now") + result = {"pull": pull_res, "push": push_res} + else: + print(f"Unknown sync subcommand: {sub}", file=sys.stderr) + return 1 + except ssc.HSPError as e: + print(f"sync failed: {e}", file=sys.stderr) + return 1 + + print(_json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + def cmd_webhook(args): """Webhook subscription management.""" from hermes_cli.webhook import webhook_command @@ -13464,6 +13562,7 @@ def main(): # cron command (parser built in hermes_cli/subcommands/cron.py) # ========================================================================= build_cron_parser(subparsers, cmd_cron=cmd_cron) + build_sync_parser(subparsers, cmd_sync=cmd_sync) # ========================================================================= # webhook command (parser built in hermes_cli/subcommands/webhook.py) diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py new file mode 100644 index 0000000000000..6473d74588a4a --- /dev/null +++ b/hermes_cli/subcommands/sync.py @@ -0,0 +1,44 @@ +"""``hermes sync`` subcommand parser (HSP/1 personal skill sync). + +Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape +(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance). + +Commands: + hermes sync status -- show gate/opt-in/head state + hermes sync pull -- pull the owner's HEAD, materialize opted-in skills + hermes sync push -- push opted-in skills to the owner's HEAD + hermes sync now -- pull then push (full reconcile) + hermes sync enable -- opt a skill into sync (M1-D) + hermes sync disable -- opt a skill out of sync + +Sync is INERT unless the resolved Nous token carries the DEV-PHASE gate claim +(tool_gateway_admin) AND a sync base URL is configured. The commands report +that state rather than failing opaquely. +""" + +from __future__ import annotations + +from typing import Callable + + +def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: + """Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``.""" + sync_parser = subparsers.add_parser( + "sync", + help="Personal skill sync (HSP/1)", + description="Sync agent-created and user-authored skills across devices.", + ) + sync_sub = sync_parser.add_subparsers(dest="sync_command") + + sync_sub.add_parser("status", help="Show sync gate, opt-in, and head state") + sync_sub.add_parser("pull", help="Pull the owner's HEAD and materialize opted-in skills") + sync_sub.add_parser("push", help="Push opted-in skills to the owner's HEAD") + sync_sub.add_parser("now", help="Reconcile now: pull then push") + + enable = sync_sub.add_parser("enable", help="Opt a skill into sync") + enable.add_argument("skill", help="Skill name (frontmatter name / directory name)") + + disable = sync_sub.add_parser("disable", help="Opt a skill out of sync") + disable.add_argument("skill", help="Skill name (frontmatter name / directory name)") + + sync_parser.set_defaults(func=cmd_sync) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py new file mode 100644 index 0000000000000..b9bd2571590c7 --- /dev/null +++ b/tests/tools/test_skills_sync_client.py @@ -0,0 +1,582 @@ +"""Tests for tools/skills_sync_client.py — the HSP/1 sync client. + +Covers, against the frozen contract (~/src/specs/collective-wisdom/ +hsp-1-contract.md): + * content addressing (full 64-hex) + canonical JSON (§2.1, §2.5) + * the DEV-PHASE gate (tool_gateway_admin) making sync inert + * the M1-D opt-in default (nothing syncs without the sync flag) + * object building (blob/tree/commit, exec mode, size limit) + * push (upload + CAS), pull (materialize), and the three-way merge / 409 + conflict paths — all against an in-process mock HSP server. + +The mock server implements the contract §3/§4 endpoint shapes with an +in-memory object store + ref table. No live server, no network. +""" + +import hashlib +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +import tools.skills_sync_client as ssc + + +# --------------------------------------------------------------------------- +# In-process mock HSP/1 server (contract §3-§4) +# --------------------------------------------------------------------------- + +class _MockState: + def __init__(self): + self.objects = {} # hash -> (kind, bytes) + self.refs = {} # name -> commit hash + self.hsp_version = "1" + self.max_object_bytes = 26214400 + self.force_conflict_once = False # inject a 409 on the next CAS + + +def _make_handler(state: _MockState): + class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): # silence + pass + + def _json(self, code, obj, extra_headers=None): + body = json.dumps(obj).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + for k, v in (extra_headers or {}).items(): + self.send_header(k, v) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?", 1)[0] + query = "" + if "?" in self.path: + query = self.path.split("?", 1)[1] + + if path == "/v1/sync/capabilities": + return self._json(200, { + "hsp_version": state.hsp_version, + "features": ["personal"], + "max_object_bytes": state.max_object_bytes, + "hash_alg": "sha256", + "auth": "bearer", + }) + + if path == "/v1/sync/refs": + prefix = "" + for part in query.split("&"): + if part.startswith("prefix="): + from urllib.parse import unquote + prefix = unquote(part[len("prefix="):]) + refs = [ + {"name": n, "hash": h} + for n, h in state.refs.items() + if n.startswith(prefix) + ] + return self._json(200, {"refs": refs}) + + if path.startswith("/v1/sync/objects/"): + obj_hash = path[len("/v1/sync/objects/"):] + if obj_hash not in state.objects: + return self._json(404, {"error": "not_found"}) + kind, data = state.objects[obj_hash] + if kind == ssc.KIND_BLOB: + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("X-HSP-Object-Type", "blob") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("X-HSP-Object-Type", kind) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + + self._json(404, {"error": "unknown"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length else b"" + + if self.path == "/v1/sync/objects": + return self._handle_put_objects(raw) + + if self.path.startswith("/v1/sync/refs/"): + return self._handle_cas(raw) + + self._json(404, {"error": "unknown"}) + + def _handle_put_objects(self, raw): + # multipart/form-data: parse parts (field=hash, filename=type, + # body=raw bytes). The server recomputes each hash and 422s on + # mismatch (contract §4.2). + ctype = self.headers.get("Content-Type", "") + if "multipart/form-data" not in ctype: + return self._json(400, {"error": "expected multipart"}) + boundary = ctype.split("boundary=", 1)[1].encode("ascii") + accepted, already = [], [] + parts = raw.split(b"--" + boundary) + for part in parts: + # Only trim the delimiter framing: a leading CRLF and a + # trailing CRLF. Do NOT strip() the whole part -- that would + # also eat legitimate trailing newlines from the object bytes. + if part.startswith(b"\r\n"): + part = part[2:] + if part.endswith(b"\r\n"): + part = part[:-2] + if not part or part == b"--": + continue + if b"\r\n\r\n" not in part: + continue + headers_blob, body = part.split(b"\r\n\r\n", 1) + hdr_text = headers_blob.decode("utf-8", "replace") + claimed_hash = None + kind = None + for line in hdr_text.split("\r\n"): + if line.lower().startswith("content-disposition"): + for token in line.split(";"): + token = token.strip() + if token.startswith('name="'): + claimed_hash = token[len('name="'):-1] + elif token.startswith('filename="'): + kind = token[len('filename="'):-1] + if claimed_hash is None: + continue + real = "sha256:" + hashlib.sha256(body).hexdigest() + if real != claimed_hash: + return self._json(422, { + "error": "hash_mismatch", "claimed": claimed_hash, + }) + if claimed_hash in state.objects: + already.append(claimed_hash) + else: + state.objects[claimed_hash] = (kind, body) + accepted.append(claimed_hash) + return self._json(200, {"accepted": accepted, "already_present": already}) + + def _handle_cas(self, raw): + from urllib.parse import unquote + name = unquote(self.path[len("/v1/sync/refs/"):]) + body = json.loads(raw.decode("utf-8")) if raw else {} + frm = body.get("from") + to = body.get("to") + if state.force_conflict_once: + state.force_conflict_once = False + return self._json(409, {"actual": state.refs.get(name, "")}) + current = state.refs.get(name) + if current != frm: + return self._json(409, {"actual": current or ""}) + state.refs[name] = to + return self._json(200, {"ref": name, "hash": to}) + + return Handler + + +@pytest.fixture +def mock_server(): + state = _MockState() + server = HTTPServer(("127.0.0.1", 0), _make_handler(state)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + try: + yield base, state + finally: + server.shutdown() + server.server_close() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_skill(skills_dir: Path, name: str, body: str = "# skill\n", *, category=None): + """Create a minimal skill dir under skills_dir; return its path.""" + parent = skills_dir / category if category else skills_dir + d = parent / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: test\n---\n{body}", encoding="utf-8" + ) + return d + + +def _jwt(claims: dict) -> str: + import jwt as _pyjwt + return _pyjwt.encode(claims, "x" * 32, algorithm="HS256") + + +# --------------------------------------------------------------------------- +# Content addressing & canonicalization (contract §2.1, §2.5, OI-5) +# --------------------------------------------------------------------------- + +class TestAddressing: + def test_full_64_hex_address(self): + addr = ssc.hsp_address(b"") + # sha256 of empty is the well-known e3b0... digest, full 64 hex. + assert addr == ( + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + assert len(addr.split(":", 1)[1]) == 64 + + def test_address_differs_from_local_truncated_namespace(self): + # OI-5: HSP full-64-hex must NOT equal the local truncated 16-hex form. + data = b"hello world" + full = ssc.hsp_address(data) + truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16] + assert full != truncated + assert len(full.split(":")[1]) == 64 + assert len(truncated.split(":")[1]) == 16 + + def test_canonical_json_sorted_no_whitespace(self): + out = ssc.canonical_json_bytes({"b": 1, "a": 2}) + assert out == b'{"a":2,"b":1}' + assert b" " not in out + assert not out.endswith(b"\n") + + def test_canonical_json_stable(self): + obj = {"type": "tree", "entries": [{"name": "x", "hash": "sha256:aa"}]} + assert ssc.canonical_json_bytes(obj) == ssc.canonical_json_bytes(dict(obj)) + + +# --------------------------------------------------------------------------- +# DEV-PHASE gate (tool_gateway_admin) + M1-D opt-in +# --------------------------------------------------------------------------- + +class TestDevGate: + def test_gate_open_with_claim(self, monkeypatch): + token = _jwt({"sub": "user1", "tool_gateway_admin": True}) + monkeypatch.setattr( + ssc, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}, raising=False, + ) + # patch the lazily-imported symbol used inside resolve_identity + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}) + ident = ssc.resolve_identity() + assert ident["dev_gate_ok"] is True + assert ident["owner"] == "user1" + + def test_gate_closed_without_claim(self, monkeypatch): + token = _jwt({"sub": "user1"}) # no tool_gateway_admin + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}) + ident = ssc.resolve_identity() + assert ident["dev_gate_ok"] is False + + def test_gate_closed_when_claim_false(self, monkeypatch): + token = _jwt({"sub": "u", "tool_gateway_admin": False}) + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}) + assert ssc.dev_gate_open() is False + + def test_maybe_push_inert_when_gate_closed(self, monkeypatch): + token = _jwt({"sub": "u"}) + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token}) + monkeypatch.setattr(ssc, "resolve_sync_base_url", lambda: "http://x") + # gate closed -> None (inert), never attempts a push + assert ssc.maybe_push_skills() is None + + def test_maybe_pull_inert_when_not_logged_in(self, monkeypatch): + import hermes_cli.auth as auth_mod + + def _raise(**kw): + raise RuntimeError("not logged in") + + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _raise) + assert ssc.maybe_pull_skills() is None + + +# --------------------------------------------------------------------------- +# Object building (contract §2.2-§2.4) +# --------------------------------------------------------------------------- + +class TestObjectBuilding: + def test_build_tree_blob_and_exec(self, tmp_path): + d = tmp_path / "skill" + d.mkdir() + (d / "SKILL.md").write_text("hello", encoding="utf-8") + script = d / "run.sh" + script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + script.chmod(0o755) + + objects = ssc.ObjectSet() + tree_hash = ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES) + assert tree_hash.startswith("sha256:") + # tree object present and canonical + kind, data = objects.objects[tree_hash] + assert kind == ssc.KIND_TREE + tree = json.loads(data) + entries = {e["name"]: e for e in tree["entries"]} + assert entries["SKILL.md"]["mode"] == ssc.MODE_FILE + assert entries["run.sh"]["mode"] == ssc.MODE_EXEC + # entries sorted by name (byte order) + names = [e["name"] for e in tree["entries"]] + assert names == sorted(names) + + def test_build_tree_dedups_identical_blobs(self, tmp_path): + d = tmp_path / "skill" + (d / "a").mkdir(parents=True) + (d / "b").mkdir(parents=True) + (d / "a" / "f.txt").write_text("same", encoding="utf-8") + (d / "b" / "f.txt").write_text("same", encoding="utf-8") + objects = ssc.ObjectSet() + ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES) + blob_hashes = [h for h, (k, _) in objects.objects.items() if k == ssc.KIND_BLOB] + # only one unique blob for the identical "same" content + assert len(set(blob_hashes)) == 1 + + def test_build_tree_skips_symlink(self, tmp_path): + d = tmp_path / "skill" + d.mkdir() + (d / "real.txt").write_text("x", encoding="utf-8") + try: + (d / "link.txt").symlink_to(d / "real.txt") + except (OSError, NotImplementedError): + pytest.skip("symlinks unsupported here") + objects = ssc.ObjectSet() + tree_hash = ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES) + tree = json.loads(objects.objects[tree_hash][1]) + names = [e["name"] for e in tree["entries"]] + assert "link.txt" not in names + assert "real.txt" in names + + def test_build_tree_rejects_oversize_blob(self, tmp_path): + d = tmp_path / "skill" + d.mkdir() + (d / "big").write_bytes(b"x" * 100) + objects = ssc.ObjectSet() + with pytest.raises(ValueError): + ssc.build_tree(d, objects, max_object_bytes=10) + + def test_build_commit_shape(self): + objects = ssc.ObjectSet() + c = ssc.build_commit( + "sha256:tree", ["sha256:p"], owner="o", device="dev", + message="m", objects=objects, ts="2026-07-18T00:00:00Z", + ) + commit = json.loads(objects.objects[c][1]) + assert commit["type"] == "commit" + assert commit["tree"] == "sha256:tree" + assert commit["parents"] == ["sha256:p"] + assert commit["author"] == {"owner": "o", "device": "dev"} + assert commit["artifact_type"] == "skill" + + +# --------------------------------------------------------------------------- +# Three-way merge decision (contract §4.4, M1-C; mirrors skills_sync.py:619) +# --------------------------------------------------------------------------- + +class TestMergeDecision: + def test_no_change(self): + assert ssc._merge_skill("b", "b", "b") == "either" + + def test_ours_only_changed(self): + assert ssc._merge_skill("b", "o", "b") == "ours" + + def test_theirs_only_changed(self): + assert ssc._merge_skill("b", "b", "t") == "theirs" + + def test_both_converged(self): + assert ssc._merge_skill("b", "x", "x") == "either" + + def test_true_overlap(self): + assert ssc._merge_skill("b", "o", "t") == "overlap" + + def test_deleted_both(self): + assert ssc._merge_skill(None, None, None) == "none" + + +# --------------------------------------------------------------------------- +# End-to-end push / pull / conflict against the mock server +# --------------------------------------------------------------------------- + +@pytest.fixture +def synced_env(tmp_path, monkeypatch): + """A HERMES_HOME with two opted-in skills + a token-carrying identity.""" + import hermes_constants + home = tmp_path / "hermes" + skills = home / "skills" + skills.mkdir(parents=True) + monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: home) + monkeypatch.setattr(ssc, "_skills_dir", lambda: skills) + + _write_skill(skills, "alpha", body="alpha v1\n") + _write_skill(skills, "beta", body="beta v1\n", category="devops") + + # Opt both into sync + treat them as eligible (bypass bundled/hub checks). + monkeypatch.setattr(ssc, "list_synced_skill_names", lambda: ["alpha", "beta"]) + + def _rel(name): + from pathlib import PurePosixPath + return {"alpha": PurePosixPath("alpha"), + "beta": PurePosixPath("devops/beta")}.get(name) + + monkeypatch.setattr(ssc, "_skill_rel_path", _rel) + + def _find(name): + return {"alpha": skills / "alpha", + "beta": skills / "devops" / "beta"}.get(name) + + import tools.skill_usage as su + monkeypatch.setattr(su, "_find_skill_dir", _find) + + token = _jwt({"sub": "owner1", "tool_gateway_admin": True}) + identity = {"api_key": token, "base_url": "http://x", "owner": "owner1", + "dev_gate_ok": True, "claims": {}} + return home, skills, identity + + +class TestEndToEnd: + def test_capabilities_version_check(self, mock_server): + base, state = mock_server + client = ssc.HSPClient(base, "tok") + caps = client.capabilities() + assert caps["hsp_version"] == "1" + ssc._check_version(caps) # no raise + + def test_version_mismatch_raises(self, mock_server): + base, state = mock_server + state.hsp_version = "2" + client = ssc.HSPClient(base, "tok") + with pytest.raises(ssc.HSPError): + ssc._check_version(client.capabilities()) + + def test_push_uploads_and_cas(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + result = ssc.push_skills(client, identity=identity) + assert result["ok"] is True + # HEAD ref advanced to our commit + head = state.refs["refs/user/owner1/HEAD"] + assert head == result["head"] + # commit object is present and well-formed + kind, data = state.objects[head] + assert kind == ssc.KIND_COMMIT + commit = json.loads(data) + assert commit["author"]["owner"] == "owner1" + assert commit["parents"] == [] # first commit + + def test_push_then_pull_materializes(self, mock_server, synced_env, tmp_path, monkeypatch): + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + ssc.push_skills(client, identity=identity) + + # Simulate a fresh device: new skills dir, same server, same opt-in. + dev2 = tmp_path / "hermes2" / "skills" + dev2.mkdir(parents=True) + monkeypatch.setattr(ssc, "_skills_dir", lambda: dev2) + monkeypatch.setattr(ssc, "read_sync_manifest", lambda: {"head": None, "skills": {}}) + saved = {} + monkeypatch.setattr(ssc, "write_sync_manifest", lambda d: saved.update(d)) + + result = ssc.pull_skills(client, identity=identity) + assert result["ok"] is True + assert "alpha" in result["updated"] + assert "devops/beta" in result["updated"] + # content materialized to disk + assert (dev2 / "alpha" / "SKILL.md").read_text().endswith("alpha v1\n") + assert (dev2 / "devops" / "beta" / "SKILL.md").read_text().endswith("beta v1\n") + + def test_push_idempotent_reupload(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + r1 = ssc.push_skills(client, identity=identity) + n_objects = len(state.objects) + # push again with no local change -> same head, objects already_present + r2 = ssc.push_skills(client, identity=identity) + assert r2["ok"] is True + assert r2["head"] == r1["head"] + assert len(state.objects) == n_objects # nothing new stored + + def test_conflict_nonoverlap_merges(self, mock_server, synced_env, monkeypatch): + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + # First push establishes a base head we record locally. + first = ssc.push_skills(client, identity=identity) + # Inject a divergent server head: change beta server-side so the next + # CAS loses. We simulate by forcing one 409 whose actual == current head + # (the server keeps the same tree, so no overlap on alpha which we edit). + (skills / "alpha" / "SKILL.md").write_text( + "---\nname: alpha\ndescription: test\n---\nalpha v2\n", encoding="utf-8" + ) + state.force_conflict_once = True + result = ssc.push_skills(client, identity=identity) + # actual == our own head -> both-sides identical -> merge commit succeeds + assert result.get("ok") is True + assert result.get("merged") is True + + def test_conflict_true_overlap_writes_conflict_ref(self, mock_server, synced_env, monkeypatch): + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + ssc.push_skills(client, identity=identity) + + # Build a DIFFERENT server-side head for the SAME skill (alpha) so the + # three-way merge sees a true overlap. We construct it via a second + # snapshot after editing alpha differently, push it directly, then make + # our local head stale and edit alpha a third way. + (skills / "alpha" / "SKILL.md").write_text( + "---\nname: alpha\ndescription: test\n---\nSERVER edit\n", encoding="utf-8" + ) + objs, root, _ = ssc.snapshot_profile(["alpha", "beta"]) + their_commit = ssc.build_commit( + root, [], owner="owner1", device="other", message="theirs", objects=objs + ) + client.put_objects(objs.objects) + state.refs["refs/user/owner1/HEAD"] = their_commit + + # Our local edit to the same skill, from the OLD base -> true overlap. + (skills / "alpha" / "SKILL.md").write_text( + "---\nname: alpha\ndescription: test\n---\nLOCAL edit\n", encoding="utf-8" + ) + result = ssc.push_skills(client, identity=identity) + assert result.get("conflict") is True + assert result["conflict_ref"].startswith("refs/user/owner1/conflict/") + assert "alpha" in result["overlapping_skills"] + # a conflict ref head was written server-side + assert result["conflict_ref"] in state.refs + + +# --------------------------------------------------------------------------- +# M1-D opt-in sidecar flag (tools/skill_usage.set_sync / is_sync_enabled) +# --------------------------------------------------------------------------- + +class TestOptInFlag: + def test_set_and_read_sync_flag(self, tmp_path, monkeypatch): + import tools.skill_usage as su + monkeypatch.setattr(su, "_skills_dir", lambda: tmp_path) + # Make the skill curation-eligible so the gated mutator writes. + monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: True) + + assert su.is_sync_enabled("foo") is False + su.set_sync("foo", True) + assert su.is_sync_enabled("foo") is True + su.set_sync("foo", False) + assert su.is_sync_enabled("foo") is False + + def test_sync_flag_ignored_for_ineligible(self, tmp_path, monkeypatch): + import tools.skill_usage as su + monkeypatch.setattr(su, "_skills_dir", lambda: tmp_path) + # Bundled/hub/external skills are not curation-eligible -> mutator no-ops. + monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: False) + su.set_sync("bundled-skill", True) + assert su.is_sync_enabled("bundled-skill") is False diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index eaf30dd41ad0d..f8fd8200a22e3 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -1320,6 +1320,56 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str: _skill_gate_bypass.reset(token) +# Debounce state for the HSP sync push hook. A burst of skill_manage writes +# (e.g. create + several write_file calls) collapses into a single push after +# a short quiet window, on a daemon timer so the agent write never blocks. +_sync_push_timer = None +_sync_push_lock = None +_SYNC_PUSH_DEBOUNCE_S = 5.0 + + +def _maybe_debounced_sync_push(skill_name: str) -> None: + """Schedule a debounced best-effort HSP push after a skill write. + + Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth, + no network). Otherwise (re)arm a daemon timer; the actual push runs through + ``skills_sync_client.maybe_push_skills`` which enforces the DEV-PHASE gate + and swallows all errors. Never blocks the caller (M1-C: agent never blocks + on sync). + """ + global _sync_push_timer, _sync_push_lock + try: + from tools.skill_usage import is_sync_enabled + + if not is_sync_enabled(skill_name): + return + except Exception: + return + + import threading + + if _sync_push_lock is None: + _sync_push_lock = threading.Lock() + + def _fire(): + try: + from tools.skills_sync_client import maybe_push_skills + + maybe_push_skills(message=f"sync: {skill_name}") + except Exception: + pass + + with _sync_push_lock: + if _sync_push_timer is not None: + try: + _sync_push_timer.cancel() + except Exception: + pass + _sync_push_timer = threading.Timer(_SYNC_PUSH_DEBOUNCE_S, _fire) + _sync_push_timer.daemon = True + _sync_push_timer.start() + + def skill_manage( action: str, name: str, @@ -1418,6 +1468,18 @@ def skill_manage( except Exception: pass + # HSP sync push hook (debounced, best-effort). Fires only AFTER the + # write gate passed (staged/unapproved writes never reach here -- the + # gate returns early above), so we never push un-reviewed content. + # Inert unless the DEV-PHASE gate is open (tool_gateway_admin on the + # token), a sync base URL is configured, and the skill is opted into + # sync. Debounced so a burst of edits collapses to one push. Never + # raises -- an agent write must never block on sync (M1-C invariant). + try: + _maybe_debounced_sync_push(name) + except Exception: + pass + return json.dumps(result, ensure_ascii=False) diff --git a/tools/skill_usage.py b/tools/skill_usage.py index dcdca87f81288..b38f64f2afbec 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -675,6 +675,26 @@ def set_pinned(skill_name: str, pinned: bool) -> None: _mutate(skill_name, _apply, require_curation_eligible=True) +def set_sync(skill_name: str, sync: bool) -> None: + """Set the HSP-sync opt-in flag on a skill's usage record (M1-D). + + Sync is OPT-IN: nothing propagates to the sync plane unless the user marks + a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by`` + on the ``.usage.json`` sidecar and is read by + ``tools.skills_sync_client.list_synced_skill_names``. Gated on curation + eligibility so bundled/hub/external skills (which never sync) can't be + marked. Provisional per the M1-D default. + """ + def _apply(rec: Dict[str, Any]) -> None: + rec["sync"] = bool(sync) + _mutate(skill_name, _apply, require_curation_eligible=True) + + +def is_sync_enabled(skill_name: str) -> bool: + """Whether a skill is opted into HSP sync (``sync: true`` in its record).""" + return get_record(skill_name).get("sync") is True + + def forget(skill_name: str) -> None: """Drop a skill's usage entry entirely. Called when the skill is deleted.""" if not skill_name: diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py new file mode 100644 index 0000000000000..d22da3d1756f5 --- /dev/null +++ b/tools/skills_sync_client.py @@ -0,0 +1,1150 @@ +#!/usr/bin/env python3 +""" +HSP/1 sync client -- Hermes Sync Protocol version 1, client (personal skill sync). + +This is the LOW-LEVEL sync layer. It builds content-addressed HSP objects +(blob/tree/commit) from local skills, talks the HSP/1 wire contract to a sync +plane (push objects + CAS a ref, pull the owner's HEAD, three-way merge on a +409), and is driven by: + + * a debounced push hook in ``skill_manage`` (after the write-gate passes), + * a periodic pull hook (``maybe_pull_skills``) at the curator tick sites, + * the ``hermes sync status|pull|push|now`` CLI. + +It lives beside ``tools/skills_sync.py`` (NOT under ``hermes_cli/``) so the +low-level sync layer never imports the CLI -- same rule the bundled-skills +sync module documents at ``skills_sync.py:43-50``. + +Contract: ``~/src/specs/collective-wisdom/hsp-1-contract.md`` (HSP/1, frozen +for Milestone 1). Endpoint shapes, object model, canonicalization, and status +codes below all trace to that document. + +--- DEV-PHASE GATE (Milestone 1) ----------------------------------------- +Client sync is INERT (no push, no pull, no-op) unless the resolved Nous +identity's access token carries ``tool_gateway_admin === true``. That claim is +minted by NAS (access-token-issuer.ts:312) and rides on the same bearer +``resolve_nous_runtime_credentials()`` returns. We decode the JWT payload +(no signature verification -- the server re-verifies) and check the claim +before doing any sync work. This is a temporary dev gate for the M1 rollout; +remove it (or replace it with a real ``sync:*`` scope / config toggle) when +sync ships to all users. + +--- OPT-IN DEFAULT (M1-D, provisional) ----------------------------------- +Nothing syncs unless the user marks a skill for sync via a ``sync`` flag on +the skill's usage sidecar (alongside ``pinned``/``created_by`` in +``.usage.json``). Only agent-created + user-authored skills under +``~/.hermes/skills/`` are eligible; bundled and hub-installed skills are +excluded. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import time +import stat as _stat +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# HSP/1 protocol constants (contract §1, §3.1) +HSP_VERSION = "1" +DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default + +# Object kinds (contract §2) +KIND_BLOB = "blob" +KIND_TREE = "tree" +KIND_COMMIT = "commit" + +# Tree entry modes (contract §2.3) +MODE_FILE = "file" +MODE_EXEC = "exec" +MODE_DIR = "dir" + +ARTIFACT_TYPE_SKILL = "skill" + + +# --------------------------------------------------------------------------- +# Content addressing (contract §2.1 / OI-5) +# +# HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT +# namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846), +# which is a truncated 16-hex digest used for local dedup. They must never be +# conflated -- we compute full digests here. +# --------------------------------------------------------------------------- + +def hsp_address(data: bytes) -> str: + """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (contract §2.1).""" + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: + """Canonical JSON serialization for tree/commit hashing (contract §2.5). + + UTF-8, keys sorted lexicographically, no insignificant whitespace + (``separators=(",", ":")``), no trailing newline. Arrays must already be + in the contract-specified order by the caller (tree entries by ``name``, + commit ``parents`` in significance order). Both client and server MUST + produce byte-identical output or a push fails ``422 hash_mismatch``. + """ + return json.dumps( + obj, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +# --------------------------------------------------------------------------- +# Identity & DEV-PHASE gate +# +# We reuse resolve_nous_runtime_credentials() for the bearer (it honors the +# cross-process file lock + portal host allowlist and refreshes as needed -- +# we do NOT reimplement refresh). The returned api_key IS the JWT bearer; we +# decode its payload (unverified) to read the dev gate claim. +# --------------------------------------------------------------------------- + +# Dev-phase gate claim (NAS access-token-issuer.ts:312). Sync is inert unless +# the resolved token carries this claim === true. Remove when sync ships GA. +DEV_GATE_CLAIM = "tool_gateway_admin" + + +class SyncInertError(RuntimeError): + """Raised (and caught by the gate-and-swallow hooks) when sync must no-op: + + not logged in, no bearer, or the dev-phase gate claim is absent/false. + """ + + +def _decode_jwt_payload_unverified(token: str) -> Dict[str, Any]: + """Decode a JWT payload WITHOUT signature verification. + + Safe here: we never trust these claims for authz -- the server re-verifies + every call. We only read the dev-gate claim to decide whether to attempt + sync at all. Mirrors the diagnostic decode in + plugins/dashboard_auth/nous/__init__.py:463. + """ + try: + import jwt # PyJWT, a core dependency + + return jwt.decode( + token, + options={"verify_signature": False, "verify_exp": False}, + ) or {} + except Exception as e: + logger.debug("skills_sync_client: JWT payload decode failed: %s", e) + return {} + + +def resolve_identity() -> Dict[str, Any]: + """Resolve the Nous bearer + owner + dev-gate flag. + + Returns a dict: ``{api_key, base_url, owner, dev_gate_ok, claims}``. + Raises :class:`SyncInertError` if not logged in / no bearer. + + ``owner`` is the token-verified subject; the server derives the real owner + from the bearer regardless (contract §0.4), so this is advisory for local + ref naming only. + """ + try: + from hermes_cli.auth import resolve_nous_runtime_credentials + + creds = resolve_nous_runtime_credentials() + except Exception as e: + raise SyncInertError(f"no Nous credentials: {e}") from e + + api_key = (creds or {}).get("api_key") + if not api_key: + raise SyncInertError("no bearer token available") + + claims = _decode_jwt_payload_unverified(api_key) + owner = ( + claims.get("sub") + or claims.get("privy_did") + or claims.get("tid") + or "unknown" + ) + dev_gate_ok = claims.get(DEV_GATE_CLAIM) is True + return { + "api_key": api_key, + "base_url": (creds or {}).get("base_url"), + "owner": str(owner), + "dev_gate_ok": dev_gate_ok, + "claims": claims, + } + + +def dev_gate_open() -> bool: + """Whether the DEV-PHASE gate permits sync. Never raises.""" + try: + return bool(resolve_identity().get("dev_gate_ok")) + except SyncInertError: + return False + except Exception as e: + logger.debug("skills_sync_client: dev_gate_open check failed: %s", e) + return False + + +# --------------------------------------------------------------------------- +# Sync-plane endpoint resolution +# +# The HSP routes are mounted under /v1/sync/ (contract §1). The base URL is +# configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env); +# it is NOT the inference base_url. When unset, sync is inert -- there is no +# server to talk to yet (the server is being built in parallel). +# --------------------------------------------------------------------------- + +def resolve_sync_base_url() -> Optional[str]: + """Resolve the HSP sync-plane base URL, or None when unconfigured. + + Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``. + Returns a base without a trailing slash (e.g. ``https://host``); the + ``/v1/sync/`` prefix is appended by the client. + """ + env = os.getenv("HERMES_SYNC_BASE_URL") + if env and env.strip(): + return env.strip().rstrip("/") + try: + # Lazy import: the low-level sync layer must not import the CLI at + # module load (skills_sync.py:43-50). A function-scoped import avoids + # the cycle -- same pattern agent/curator.py:141 uses for config. + from hermes_cli.config import load_config + + cfg = load_config() or {} + sync_cfg = cfg.get("sync") or {} + base = sync_cfg.get("base_url") + if isinstance(base, str) and base.strip(): + return base.strip().rstrip("/") + except Exception as e: + logger.debug("skills_sync_client: config sync.base_url read failed: %s", e) + return None + + +# --------------------------------------------------------------------------- +# Local skill eligibility + the M1-D opt-in "sync" flag +# +# Only agent-created + user-authored skills under ~/.hermes/skills/ sync. +# Bundled (.bundled_manifest) and hub-installed skills are excluded. Sync is +# opt-in: a skill only syncs when its usage-sidecar carries ``sync: true``. +# --------------------------------------------------------------------------- + +def _skills_dir() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "skills" + + +def is_sync_eligible(skill_name: str) -> bool: + """Whether *skill_name* is a candidate for HSP sync (before the opt-in check). + + Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT + hub-installed, NOT an external-dir skill. Mirrors the exclusion logic used + by the curator (tools/skill_usage.py). + """ + try: + from tools.skill_usage import is_bundled, is_hub_installed, _find_skill_dir + from agent.skill_utils import is_external_skill_path + except Exception: + return False + if is_bundled(skill_name) or is_hub_installed(skill_name): + return False + skill_dir = _find_skill_dir(skill_name) + if skill_dir is None: + return False + if is_external_skill_path(skill_dir): + return False + return True + + +def list_synced_skill_names() -> List[str]: + """Return the names of skills the user has opted into sync (``sync: true``) + AND that remain eligible. Sorted, deduped.""" + try: + from tools.skill_usage import load_usage + except Exception: + return [] + names = [] + for name, rec in (load_usage() or {}).items(): + if isinstance(rec, dict) and rec.get("sync") is True and is_sync_eligible(name): + names.append(name) + return sorted(set(names)) + + +# --------------------------------------------------------------------------- +# Object building -- turn a skill directory into HSP blob/tree/commit objects +# +# A skill dir becomes one tree (contract §2.3). Each file is a blob; each +# subdir a nested tree. The profile-root tree (contract §2.3: "a tree whose +# entries are category trees") is built from the set of synced skill trees. +# --------------------------------------------------------------------------- + +class ObjectSet: + """Accumulates HSP objects to push: hash -> (kind, bytes). + + Deduped by content address, so identical blobs across skills upload once. + """ + + def __init__(self) -> None: + self.objects: Dict[str, Tuple[str, bytes]] = {} + + def add(self, kind: str, data: bytes) -> str: + addr = hsp_address(data) + self.objects.setdefault(addr, (kind, data)) + return addr + + def __len__(self) -> int: + return len(self.objects) + + +def _file_mode(path: Path) -> str: + """Return the HSP tree mode for a regular file: ``exec`` if +x else ``file`` + (contract §2.3). No symlinks / other modes are emitted.""" + try: + if path.stat().st_mode & (_stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH): + return MODE_EXEC + except OSError: + pass + return MODE_FILE + + +def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> str: + """Recursively build HSP objects for *dir_path*; return the tree address. + + Regular files become blobs; subdirectories become nested trees. Symlinks, + sockets, and other special files are skipped (contract §2.3 security: no + symlinks). Blobs over *max_object_bytes* raise :class:`ValueError` so the + caller can surface / skip the artifact (contract §4.3 -> 413). + """ + entries: List[Dict[str, str]] = [] + for child in sorted(dir_path.iterdir(), key=lambda p: p.name): + if child.is_symlink(): + logger.debug("skills_sync_client: skipping symlink %s", child) + continue + if child.is_dir(): + sub_hash = build_tree(child, objects, max_object_bytes=max_object_bytes) + entries.append( + {"name": child.name, "kind": KIND_TREE, "hash": sub_hash, "mode": MODE_DIR} + ) + elif child.is_file(): + data = child.read_bytes() + if len(data) > max_object_bytes: + raise ValueError( + f"file {child} is {len(data)} bytes > max_object_bytes " + f"{max_object_bytes} (contract §4.3)" + ) + blob_hash = objects.add(KIND_BLOB, data) + entries.append( + { + "name": child.name, + "kind": KIND_BLOB, + "hash": blob_hash, + "mode": _file_mode(child), + } + ) + # else: skip special files + # Entries sorted by name (byte order) for canonicalization (contract §2.3). + entries.sort(key=lambda e: e["name"]) + tree_obj = {"type": KIND_TREE, "entries": entries} + return objects.add(KIND_TREE, canonical_json_bytes(tree_obj)) + + +def build_commit( + tree_hash: str, + parents: List[str], + *, + owner: str, + device: str, + message: str, + objects: ObjectSet, + ts: Optional[str] = None, +) -> str: + """Build a commit object (contract §2.4) and return its address. + + ``parents``: 0 for first commit, 1 for a normal edit, 2 for a merge commit + (order significant: parents[0] = base fast-forwarded from, parents[1] = + the other head being merged). + """ + commit_obj = { + "type": KIND_COMMIT, + "tree": tree_hash, + "parents": list(parents), + "author": {"owner": owner, "device": device}, + "ts": ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "message": message, + "artifact_type": ARTIFACT_TYPE_SKILL, + } + return objects.add(KIND_COMMIT, canonical_json_bytes(commit_obj)) + + +def stable_device_id() -> str: + """Return an opaque, stable per-device id for commit ``author.device`` + (contract §2.4 -- advisory, never an auth input). Persisted under + ~/.hermes/skills/.sync_device_id.""" + path = _skills_dir() / ".sync_device_id" + try: + if path.exists(): + val = path.read_text(encoding="utf-8").strip() + if val: + return val + except OSError: + pass + import uuid + + val = uuid.uuid4().hex + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(val, encoding="utf-8") + except OSError as e: + logger.debug("skills_sync_client: could not persist device id: %s", e) + return val + + +# --------------------------------------------------------------------------- +# HSP/1 wire client +# +# Thin requests-based client for the endpoints in contract §3-§4. Uploads all +# new objects (batch), then CAS-es the ref. A 409 returns the actual head for +# the caller's three-way merge. Auth is the Nous bearer resolved above. +# --------------------------------------------------------------------------- + +class HSPError(RuntimeError): + """A non-recoverable HSP wire error (4xx that the client can't retry).""" + + def __init__(self, message: str, *, status: Optional[int] = None): + super().__init__(message) + self.status = status + + +class HSPConflict(RuntimeError): + """CAS lost (409). ``actual`` is the current head to merge against + (contract §4.4). NOT a rejection -- pushed objects are already durable.""" + + def __init__(self, actual: str): + super().__init__(f"CAS conflict; actual head {actual}") + self.actual = actual + + +class HSPClient: + """HSP/1 client bound to a base URL + bearer (contract §1, routes under + ``/v1/sync/``).""" + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0): + self.base = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + import requests # core dependency + + self._session = requests.Session() + self._session.headers["Authorization"] = f"Bearer {api_key}" + + def _url(self, path: str) -> str: + return f"{self.base}/v1/sync/{path.lstrip('/')}" + + # -- capability & read ------------------------------------------------- + + def capabilities(self) -> Dict[str, Any]: + """GET /v1/sync/capabilities (contract §3.1). No auth required.""" + r = self._session.get(self._url("capabilities"), timeout=self.timeout) + if r.status_code != 200: + raise HSPError(f"capabilities failed: {r.status_code}", status=r.status_code) + return r.json() + + def get_refs(self, prefix: str) -> List[Dict[str, str]]: + """GET /v1/sync/refs?prefix=... (contract §3.2).""" + r = self._session.get( + self._url("refs"), params={"prefix": prefix}, timeout=self.timeout + ) + if r.status_code != 200: + raise HSPError(f"get_refs failed: {r.status_code}", status=r.status_code) + return (r.json() or {}).get("refs", []) + + def get_object(self, obj_hash: str) -> Tuple[str, bytes]: + """GET /v1/sync/objects/:hash (contract §3.3). Returns (kind, bytes). + + Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response + (application/octet-stream) is returned as ``blob``. + """ + r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout) + if r.status_code == 404: + raise HSPError(f"object {obj_hash} not found", status=404) + if r.status_code == 403: + raise HSPError(f"object {obj_hash} not readable", status=403) + if r.status_code != 200: + raise HSPError(f"get_object failed: {r.status_code}", status=r.status_code) + kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB + return kind, r.content + + def get_commit_json(self, commit_hash: str) -> Dict[str, Any]: + """Fetch a commit object and parse its canonical JSON.""" + kind, data = self.get_object(commit_hash) + if kind != KIND_COMMIT: + raise HSPError(f"{commit_hash} is {kind}, expected commit") + return json.loads(data.decode("utf-8")) + + def get_tree_json(self, tree_hash: str) -> Dict[str, Any]: + """Fetch a tree object and parse its canonical JSON.""" + kind, data = self.get_object(tree_hash) + if kind != KIND_TREE: + raise HSPError(f"{tree_hash} is {kind}, expected tree") + return json.loads(data.decode("utf-8")) + + # -- write ------------------------------------------------------------- + + def put_objects(self, objects: Dict[str, Tuple[str, bytes]]) -> Dict[str, Any]: + """POST /v1/sync/objects (contract §4.2). Batch multi-object upload. + + Contract §1 requires raw object bytes on the wire (NOT base64-in-JSON), + and §4.2 specifies "a length-prefixed or multipart stream of + {hash, type, bytes}". We use multipart/form-data: one part per object, + the part's field name = the claimed ``sha256:`` hash, its + ``filename`` carries the object ``type`` (blob|tree|commit), and the + part body is the raw object bytes. The server recomputes each hash from + the received bytes and rejects the whole batch with 422 on mismatch. + Idempotent: a known hash is a no-op ``already_present``. + + NOTE (framing choice within contract latitude): §4.2 says "length- + prefixed OR multipart"; this picks multipart/form-data with + (field=hash, filename=type, body=raw-bytes). The server strand must + parse the same framing -- flagged for cross-strand alignment. + """ + # (field_name, (filename, raw_bytes, content_type)) + files = [ + (h, (kind, data, "application/octet-stream")) + for h, (kind, data) in objects.items() + ] + r = self._session.post( + self._url("objects"), files=files, timeout=self.timeout + ) + if r.status_code == 413: + raise HSPError("object too large (413)", status=413) + if r.status_code == 422: + raise HSPError(f"hash_mismatch (422): {r.text}", status=422) + if r.status_code not in (200, 201): + raise HSPError(f"put_objects failed: {r.status_code}", status=r.status_code) + return r.json() if r.content else {} + + def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]: + """POST /v1/sync/refs/:name -- atomic compare-and-swap (contract §4.4). + + Raises :class:`HSPConflict` (carrying the actual head) on 409. + """ + r = self._session.post( + self._url(f"refs/{name}"), + json={"from": from_hash, "to": to_hash}, + timeout=self.timeout, + ) + if r.status_code == 409: + actual = (r.json() or {}).get("actual", "") + raise HSPConflict(actual) + if r.status_code == 403: + raise HSPError("forbidden (403) -- owner/permission", status=403) + if r.status_code != 200: + raise HSPError(f"cas_ref failed: {r.status_code}", status=r.status_code) + return r.json() if r.content else {} + + +# --------------------------------------------------------------------------- +# HSP sync manifest (client-local, FULL-digest namespace) +# +# Records, per synced skill, the last commit HEAD we pushed/pulled and the +# tree hash of the on-disk content at that point. Distinct from the bundled +# manifest (skills_sync.py, truncated local content_hash namespace). Lives at +# ~/.hermes/skills/.sync_manifest as JSON. +# --------------------------------------------------------------------------- + +def _sync_manifest_path() -> Path: + return _skills_dir() / ".sync_manifest" + + +def read_sync_manifest() -> Dict[str, Any]: + """Read the HSP sync manifest. Returns {} on missing/corrupt. + + Shape: ``{"head": "sha256:...|null", "skills": {name: {tree, commit}}}``. + ``head`` is the last profile-root HEAD commit we reconciled with. + """ + path = _sync_manifest_path() + if not path.exists(): + return {"head": None, "skills": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("head", None) + data.setdefault("skills", {}) + return data + except (OSError, json.JSONDecodeError) as e: + logger.debug("skills_sync_client: sync manifest read failed: %s", e) + return {"head": None, "skills": {}} + + +def write_sync_manifest(data: Dict[str, Any]) -> None: + """Write the HSP sync manifest atomically. Best-effort.""" + import tempfile + + path = _sync_manifest_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sync_manifest_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except Exception as e: + logger.debug("skills_sync_client: sync manifest write failed: %s", e) + + +# --------------------------------------------------------------------------- +# Tree materialization (pull) -- write an HSP tree back to a skill directory +# --------------------------------------------------------------------------- + +def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None: + """Write the HSP tree at *tree_hash* into *dest* (created if needed). + + Blobs become files (with +x restored for ``exec`` mode), nested trees + become subdirectories. Does NOT delete files absent from the tree -- the + caller decides removal semantics. Refuses path traversal via entry names. + """ + dest.mkdir(parents=True, exist_ok=True) + tree = client.get_tree_json(tree_hash) + for entry in tree.get("entries", []): + name = entry.get("name", "") + if not name or "/" in name or name in (".", ".."): + logger.warning("skills_sync_client: skipping unsafe tree entry %r", name) + continue + target = dest / name + kind = entry.get("kind") + if kind == KIND_TREE: + materialize_tree(client, entry["hash"], target) + elif kind == KIND_BLOB: + _, data = client.get_object(entry["hash"]) + target.write_bytes(data) + if entry.get("mode") == MODE_EXEC: + try: + st = target.stat().st_mode + target.chmod(st | _stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Profile snapshot -- build the objects + per-skill tree map for a push +# +# The profile root is a tree whose entries mirror each synced skill's relative +# path under ~/.hermes/skills/ (contract §2.3: "the profile root is a tree +# whose entries are category trees"). Only opted-in, eligible skills are +# included (M1-D opt-in + eligibility). +# --------------------------------------------------------------------------- + +def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]: + """Return the skill's path relative to ~/.hermes/skills/ (posix), or None.""" + try: + from tools.skill_usage import _find_skill_dir + except Exception: + return None + skill_dir = _find_skill_dir(skill_name) + if skill_dir is None: + return None + try: + rel = skill_dir.resolve().relative_to(_skills_dir().resolve()) + except (OSError, ValueError): + return None + return PurePosixPath(rel.as_posix()) + + +def snapshot_profile( + skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES +) -> Tuple[ObjectSet, str, Dict[str, str]]: + """Build all HSP objects for *skill_names* + the profile-root tree. + + Returns ``(objects, root_tree_hash, skill_tree_map)`` where + ``skill_tree_map`` is ``{skill_name: tree_hash}``. Skills whose blobs + exceed *max_object_bytes* are skipped (surfaced via logger). + + The root tree nests category directories: a skill at ``devops/foo`` yields + a root entry ``devops`` (tree) containing ``foo`` (tree). Flat skills yield + a direct root entry. + """ + from tools.skill_usage import _find_skill_dir + + objects = ObjectSet() + skill_tree_map: Dict[str, str] = {} + # Nested dict representing the root: {name: {"__tree__": hash} | subdict} + root: Dict[str, Any] = {} + + for name in sorted(set(skill_names)): + rel = _skill_rel_path(name) + skill_dir = _find_skill_dir(name) + if rel is None or skill_dir is None: + continue + try: + tree_hash = build_tree(skill_dir, objects, max_object_bytes=max_object_bytes) + except ValueError as e: + logger.warning("skills_sync_client: skipping %s: %s", name, e) + continue + skill_tree_map[name] = tree_hash + # Insert into the nested root structure by relative path parts. + parts = list(rel.parts) + node = root + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = {"__tree__": tree_hash} + + root_hash = _build_root_tree(root, objects) + return objects, root_hash, skill_tree_map + + +def _build_root_tree(node: Dict[str, Any], objects: ObjectSet) -> str: + """Recursively canonicalize the nested root structure into HSP trees.""" + entries: List[Dict[str, str]] = [] + for name, child in node.items(): + if isinstance(child, dict) and "__tree__" in child and len(child) == 1: + entries.append( + {"name": name, "kind": KIND_TREE, "hash": child["__tree__"], "mode": MODE_DIR} + ) + else: + sub_hash = _build_root_tree(child, objects) + entries.append( + {"name": name, "kind": KIND_TREE, "hash": sub_hash, "mode": MODE_DIR} + ) + entries.sort(key=lambda e: e["name"]) + tree_obj = {"type": KIND_TREE, "entries": entries} + return objects.add(KIND_TREE, canonical_json_bytes(tree_obj)) + + +# --------------------------------------------------------------------------- +# Ref naming (contract §2.6) +# --------------------------------------------------------------------------- + +def user_head_ref(owner: str) -> str: + return f"refs/user/{owner}/HEAD" + + +def user_conflict_ref(owner: str, n: int) -> str: + return f"refs/user/{owner}/conflict/{n}" + + +def _root_tree_of_commit(client: "HSPClient", commit_hash: str) -> str: + """Return the tree hash referenced by a commit.""" + return client.get_commit_json(commit_hash)["tree"] + + +def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, str]: + """Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``. + + A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the + root tree; a subtree with a SKILL.md is treated as a skill leaf keyed by + its path, so category nesting is preserved. + """ + result: Dict[str, str] = {} + + def _walk(tree_hash: str, prefix: str) -> None: + tree = client.get_tree_json(tree_hash) + entries = tree.get("entries", []) + has_skill_md = any( + e.get("name") == "SKILL.md" and e.get("kind") == KIND_BLOB for e in entries + ) + if has_skill_md and prefix: + result[prefix] = tree_hash + return + for e in entries: + if e.get("kind") == KIND_TREE: + child_prefix = f"{prefix}/{e['name']}" if prefix else e["name"] + _walk(e["hash"], child_prefix) + + _walk(root_tree_hash, "") + return result + + +def _check_version(caps: Dict[str, Any]) -> None: + """Reject an incompatible server major version (contract §1).""" + ver = str(caps.get("hsp_version") or "") + major = ver.split(".", 1)[0] + if major != HSP_VERSION: + raise HSPError(f"incompatible HSP version {ver!r} (client speaks {HSP_VERSION})") + + +# --------------------------------------------------------------------------- +# Push +# --------------------------------------------------------------------------- + +def push_skills( + client: Optional["HSPClient"] = None, + *, + skill_names: Optional[List[str]] = None, + identity: Optional[Dict[str, Any]] = None, + message: str = "hermes skill sync", +) -> Dict[str, Any]: + """Push opted-in skills to the owner's HEAD (contract §4). + + Uploads all new objects, then CAS-es ``refs/user//HEAD``. On a 409, + fetches the actual head, three-way merges, and retries once (§4.4 / M1-C). + Returns a result dict; never raises for the inert / no-op cases. + """ + if identity is None: + identity = resolve_identity() + owner = identity["owner"] + if client is None: + base = resolve_sync_base_url() + if not base: + return {"ok": False, "reason": "no sync base url configured", "noop": True} + client = HSPClient(base, identity["api_key"]) + + if skill_names is None: + skill_names = list_synced_skill_names() + if not skill_names: + return {"ok": True, "reason": "no skills opted into sync", "noop": True} + + caps = client.capabilities() + _check_version(caps) + max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES) + + objects, root_hash, _ = snapshot_profile(skill_names, max_object_bytes=max_bytes) + + manifest = read_sync_manifest() + base_head = manifest.get("head") + + # Idempotency: if the profile-root tree is unchanged since our last push, + # there is nothing to propagate -- skip building an empty commit (contract + # objects are immutable, so an identical tree hash means identical content). + if base_head and manifest.get("root") == root_hash: + return {"ok": True, "head": base_head, "reason": "unchanged", "noop": True} + + device = stable_device_id() + parents = [base_head] if base_head else [] + commit_hash = build_commit( + root_hash, parents, owner=owner, device=device, message=message, objects=objects + ) + + client.put_objects(objects.objects) + ref = user_head_ref(owner) + + try: + client.cas_ref(ref, base_head, commit_hash) + manifest["head"] = commit_hash + manifest["root"] = root_hash + write_sync_manifest(manifest) + return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)} + except HSPConflict as conflict: + return _resolve_push_conflict( + client, identity, conflict.actual, root_hash, commit_hash, + objects, skill_names, message, base_head, + ) + + +# --------------------------------------------------------------------------- +# Conflict resolution / three-way merge (contract §4.4, M1-C) +# +# On a 409 the server hands back the actual head. We fetch it, three-way merge +# per skill against the base we forked from, reusing the origin/user/incoming +# decision semantics of skills_sync.py (_is_tracked_user_modification + +# the decision block at skills_sync.py:619-643): +# +# * base == ours == theirs -> nothing to do +# * ours == base, theirs moved -> take theirs (fast-forward incoming) +# * theirs == base, ours moved -> keep ours (our local edit) +# * both moved, ours == theirs -> converged; take either +# * both moved, differ -> TRUE OVERLAP -> conflict head +# +# Non-overlapping merges (each side changed a DIFFERENT skill) produce a merge +# commit (2 parents) and retry the CAS. A true overlap (both sides changed the +# SAME skill differently) is written to refs/user//conflict/ and +# surfaced for out-of-band resolution. +# --------------------------------------------------------------------------- + +def _resolve_push_conflict( + client: "HSPClient", + identity: Dict[str, Any], + actual_head: str, + our_root: str, + our_commit: str, + objects: "ObjectSet", + skill_names: List[str], + message: str, + base_head: Optional[str], +) -> Dict[str, Any]: + owner = identity["owner"] + device = stable_device_id() + + theirs_root = _root_tree_of_commit(client, actual_head) + base_root = _root_tree_of_commit(client, base_head) if base_head else None + + ours_trees = _skill_trees_of_root(client, our_root) + theirs_trees = _skill_trees_of_root(client, theirs_root) + base_trees = _skill_trees_of_root(client, base_root) if base_root else {} + + merged: Dict[str, str] = {} + overlaps: List[str] = [] + all_paths = set(ours_trees) | set(theirs_trees) | set(base_trees) + for path in all_paths: + o = ours_trees.get(path) + t = theirs_trees.get(path) + b = base_trees.get(path) + decision = _merge_skill(b, o, t) + if decision == "overlap": + overlaps.append(path) + # Keep OURS on the surfaced conflict head; theirs is retained + # server-side under the conflict ref for out-of-band resolution. + if o is not None: + merged[path] = o + elif decision == "ours" and o is not None: + merged[path] = o + elif decision == "theirs" and t is not None: + merged[path] = t + elif decision == "either": + merged[path] = o if o is not None else t # type: ignore[assignment] + # decision == "none": skill deleted on the winning side -> drop + + if overlaps: + # TRUE OVERLAP -> write a conflict head and surface it (M1-C). + n = _next_conflict_index(client, owner) + conflict_ref = user_conflict_ref(owner, n) + try: + client.cas_ref(conflict_ref, None, our_commit) + except HSPConflict: + pass # someone else grabbed this index; the head still exists + return { + "ok": False, + "conflict": True, + "conflict_ref": conflict_ref, + "overlapping_skills": sorted(overlaps), + "actual_head": actual_head, + "message": ( + f"{len(overlaps)} skill(s) changed on both sides; wrote " + f"{conflict_ref}. Resolve out-of-band (hermes sync / NAS UI)." + ), + } + + # Non-overlap -> build a merge commit (parents: base->actual, ours) and + # retry the CAS against the actual head. + merge_objects = ObjectSet() + # Re-add our objects so the merge push is self-contained (idempotent). + for h, (kind, data) in objects.objects.items(): + merge_objects.objects[h] = (kind, data) + merged_root = _assemble_root_from_skill_trees(client, merged, merge_objects) + merge_commit = build_commit( + merged_root, + [actual_head, our_commit], + owner=owner, + device=device, + message=f"merge: {message}", + objects=merge_objects, + ) + client.put_objects(merge_objects.objects) + try: + client.cas_ref(user_head_ref(owner), actual_head, merge_commit) + except HSPConflict as c2: + return { + "ok": False, + "conflict": True, + "message": f"merge CAS lost again (head now {c2.actual}); retry sync.", + "actual_head": c2.actual, + } + manifest = read_sync_manifest() + manifest["head"] = merge_commit + manifest["root"] = merged_root + write_sync_manifest(manifest) + return {"ok": True, "head": merge_commit, "merged": True} + + +def _merge_skill(base: Optional[str], ours: Optional[str], theirs: Optional[str]) -> str: + """Three-way decision for one skill's tree hash. + + Returns one of: ``ours``, ``theirs``, ``either``, ``overlap``, ``none``. + Mirrors the origin/user/incoming decision block of skills_sync.py:619-643: + a side "modified" the skill when its hash differs from the common base + (analogous to ``_is_tracked_user_modification(origin, current)``). + """ + if ours == theirs: + return "either" if ours is not None else "none" + ours_changed = ours != base + theirs_changed = theirs != base + if ours_changed and not theirs_changed: + return "ours" + if theirs_changed and not ours_changed: + return "theirs" + # both changed and differ + return "overlap" + + +def _assemble_root_from_skill_trees( + client: "HSPClient", skill_trees: Dict[str, str], objects: "ObjectSet" +) -> str: + """Build a profile-root tree object from ``{posix_rel_path: tree_hash}``. + + Rebuilds the intermediate category trees. The referenced skill trees are + assumed already durable (they came from either side of the merge); only + the new intermediate/root tree objects are added to *objects*. + """ + root: Dict[str, Any] = {} + for path, tree_hash in skill_trees.items(): + parts = PurePosixPath(path).parts + node = root + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = {"__tree__": tree_hash} + return _build_root_tree(root, objects) + + +def _next_conflict_index(client: "HSPClient", owner: str) -> int: + """Pick the next free conflict ref index for the owner.""" + try: + refs = client.get_refs(f"refs/user/{owner}/conflict/") + except HSPError: + return 1 + used = [] + for r in refs: + name = r.get("name", "") + tail = name.rsplit("/", 1)[-1] + if tail.isdigit(): + used.append(int(tail)) + return (max(used) + 1) if used else 1 + + +# --------------------------------------------------------------------------- +# Pull +# --------------------------------------------------------------------------- + +def pull_skills( + client: Optional["HSPClient"] = None, + *, + identity: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Pull the owner's HEAD and materialize opted-in skills to disk. + + Fetches ``refs/user//HEAD``; if it advanced past our recorded head, + walks the profile-root tree and writes each skill tree into + ~/.hermes/skills/. Only paths the user has opted into (``sync: true``) are + materialized, so a pull never resurrects a skill the user hasn't chosen. + Best-effort; returns a result dict. + """ + if identity is None: + identity = resolve_identity() + owner = identity["owner"] + if client is None: + base = resolve_sync_base_url() + if not base: + return {"ok": False, "reason": "no sync base url configured", "noop": True} + client = HSPClient(base, identity["api_key"]) + + caps = client.capabilities() + _check_version(caps) + + refs = client.get_refs(user_head_ref(owner)) + head = None + for r in refs: + if r.get("name") == user_head_ref(owner): + head = r.get("hash") + break + if not head: + return {"ok": True, "reason": "no remote HEAD yet", "noop": True} + + manifest = read_sync_manifest() + if head == manifest.get("head"): + return {"ok": True, "reason": "already up to date", "head": head, "noop": True} + + root_tree = _root_tree_of_commit(client, head) + remote_trees = _skill_trees_of_root(client, root_tree) + + opted_in = set(_opted_in_rel_paths()) + updated = [] + for path, tree_hash in remote_trees.items(): + # Opt-in gate on pull: only materialize skills the user chose to sync. + if opted_in and path not in opted_in: + continue + dest = _skills_dir() / path + materialize_tree(client, tree_hash, dest) + updated.append(path) + + manifest["head"] = head + write_sync_manifest(manifest) + return {"ok": True, "head": head, "updated": sorted(updated)} + + +def _opted_in_rel_paths() -> List[str]: + """Relative posix paths of skills the user has opted into sync.""" + paths = [] + for name in list_synced_skill_names(): + rel = _skill_rel_path(name) + if rel is not None: + paths.append(rel.as_posix()) + return paths + + +# --------------------------------------------------------------------------- +# Gated public entrypoints (gate-and-swallow) +# +# maybe_pull_skills / maybe_push_skills clone the shape of the curator's +# maybe_run_curator (agent/curator.py:1998): best-effort, never raise, return +# a result dict or None. The DEV-PHASE gate is checked first -- sync is inert +# (no push, no pull, no-op) unless tool_gateway_admin === true on the token. +# --------------------------------------------------------------------------- + +def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[str, Any]]: + """Best-effort push if all gates pass. Returns a result dict or None. + Never raises. Called from the debounced skill_manage push hook.""" + try: + identity = resolve_identity() + if not identity.get("dev_gate_ok"): + return None # DEV-PHASE gate: inert without tool_gateway_admin + if not resolve_sync_base_url(): + return None + if not list_synced_skill_names(): + return None + return push_skills(identity=identity, message=message) + except Exception as e: + logger.debug("skills_sync_client: maybe_push_skills failed: %s", e, exc_info=True) + return None + + +def maybe_pull_skills() -> Optional[Dict[str, Any]]: + """Best-effort pull if all gates pass. Returns a result dict or None. + Never raises. Invoked at the curator tick sites (gateway housekeeping loop + + CLI startup).""" + try: + identity = resolve_identity() + if not identity.get("dev_gate_ok"): + return None # DEV-PHASE gate: inert without tool_gateway_admin + if not resolve_sync_base_url(): + return None + return pull_skills(identity=identity) + except Exception as e: + logger.debug("skills_sync_client: maybe_pull_skills failed: %s", e, exc_info=True) + return None + + +def sync_status() -> Dict[str, Any]: + """Return a status snapshot for ``hermes sync status``. Never raises.""" + status: Dict[str, Any] = { + "dev_gate_ok": False, + "logged_in": False, + "base_url": resolve_sync_base_url(), + "opted_in_skills": [], + "local_head": None, + "owner": None, + } + try: + identity = resolve_identity() + status["logged_in"] = True + status["owner"] = identity.get("owner") + status["dev_gate_ok"] = bool(identity.get("dev_gate_ok")) + except SyncInertError: + pass + except Exception as e: + logger.debug("skills_sync_client: sync_status identity failed: %s", e) + try: + status["opted_in_skills"] = list_synced_skill_names() + status["local_head"] = read_sync_manifest().get("head") + except Exception: + pass + return status From 3c4154aca4768e67936cd978249d38aa8b00b0fe Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 23 Jul 2026 08:01:05 +1000 Subject: [PATCH 02/36] =?UTF-8?q?feat(sync):=20opt-in=20as=20content=20syn?= =?UTF-8?q?c-manifest=20(design.md=20=C2=A72.8),=20cross-device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the device-local opt-in model (`.usage.json` `sync` flag as the sole source of truth) with the §2.8 content model: a root-level `sync-manifest` blob in the tree at `refs/user//HEAD` recording per-skill {name, enabled}, matching gateway-gateway src/sync/manifest.ts byte-for-byte. - build/parse_sync_manifest: canonical {type,version:1,skills:[{name,enabled}]}, strict parse (malformed != empty). - snapshot_profile embeds the manifest as a root-level blob alongside skill subtrees; the skill walk skips it (blob, not a SKILL.md-bearing tree). - pull reconciles local opt-in intent FROM the plane manifest, so a skill opted in on one device becomes opted in on the others (opt-in is now cross-device, not per-device). Never silently disables a locally-enabled skill on pull. - .usage.json `sync` flag kept as the editable local intent; plane manifest is authoritative. Cross-repo byte-compat verified: the Python client's manifest bytes parse cleanly through gateway-gateway's real parseSyncManifest (tsx harness). Tests: 34 pass incl. 5 new (roundtrip, wire shape, strict-reject, root-blob embed, pull adopts opt-in from manifest). --- tests/tools/test_skills_sync_client.py | 87 +++++++++++ tools/skills_sync_client.py | 194 +++++++++++++++++++++++-- 2 files changed, 271 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index b9bd2571590c7..815967dc79527 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -580,3 +580,90 @@ class TestOptInFlag: monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: False) su.set_sync("bundled-skill", True) assert su.is_sync_enabled("bundled-skill") is False + + +# --------------------------------------------------------------------------- +# §2.8 sync-manifest — opt-in as content in the sync plane (cross-device) +# --------------------------------------------------------------------------- + +class TestSyncManifest: + def test_build_parse_roundtrip(self): + data = ssc.build_sync_manifest_bytes({"beta": True, "alpha": False}) + parsed = ssc.parse_sync_manifest(data) + assert parsed == {"alpha": False, "beta": True} + + def test_manifest_wire_shape(self): + # Must match gateway-gateway src/sync/manifest.ts: type + version:1 + + # skills:[{name,enabled}]. Skills sorted by name for a stable address. + import json + data = ssc.build_sync_manifest_bytes({"z": True, "a": True}) + obj = json.loads(data.decode("utf-8")) + assert obj["type"] == "sync-manifest" + assert obj["version"] == 1 + assert obj["skills"] == [ + {"name": "a", "enabled": True}, + {"name": "z", "enabled": True}, + ] + + def test_parse_rejects_malformed(self): + # Strict: unknown type, bad version, non-array skills, malformed entry. + assert ssc.parse_sync_manifest(b"not json") is None + assert ssc.parse_sync_manifest(b'{"type":"nope","version":1,"skills":[]}') is None + assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":2,"skills":[]}') is None + assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":{}}') is None + assert ( + ssc.parse_sync_manifest( + b'{"type":"sync-manifest","version":1,"skills":[{"name":"x"}]}' + ) + is None + ) + # A malformed manifest must NOT be mistaken for "no skills opted in". + assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":[]}') == {} + + def test_snapshot_embeds_manifest_root_blob(self, mock_server, synced_env): + # snapshot_profile must add a root-level `sync-manifest` blob recording + # the opted-in set, alongside the skill subtrees, so opt-in is durable + # plane content. Read it back via read_manifest_of_root. + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + + objs, root_hash, skill_map = ssc.snapshot_profile(["alpha", "beta"]) + client.put_objects(objs.objects) + + manifest = ssc.read_manifest_of_root(client, root_hash) + assert manifest == {"alpha": True, "beta": True} + + # The manifest is a root-level BLOB, not a skill subtree, so the skill + # walk must not surface it as a skill. + trees = ssc._skill_trees_of_root(client, root_hash) + assert "sync-manifest" not in trees + assert set(trees) == {"alpha", "devops/beta"} + + def test_pull_adopts_opt_in_from_manifest(self, mock_server, synced_env, monkeypatch): + # A skill opted in on device A (present + enabled in the plane manifest) + # becomes opted in locally on pull, even if this device had it disabled. + base, state = mock_server + home, skills, identity = synced_env + client = ssc.HSPClient(base, identity["api_key"]) + + # Device A pushes alpha+beta (manifest enables both). + ssc.push_skills(client, identity=identity) + + # Simulate device B: local opt-in intent is EMPTY, but eligibility passes. + adopted = {} + import tools.skill_usage as su + monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: True) + monkeypatch.setattr(su, "is_sync_enabled", lambda name: False) + monkeypatch.setattr(su, "set_sync", lambda name, val: adopted.__setitem__(name, val)) + # Local head unknown so the pull actually runs. + monkeypatch.setattr(ssc, "read_sync_manifest", lambda: {"head": None, "skills": {}}) + monkeypatch.setattr(ssc, "write_sync_manifest", lambda d: None) + # No local opt-in gate (so materialize isn't the thing under test). + monkeypatch.setattr(ssc, "_opted_in_rel_paths", lambda: []) + + result = ssc.pull_skills(client, identity=identity) + assert result["ok"] is True + # Both skills from the plane manifest were adopted into local opt-in. + assert adopted == {"alpha": True, "beta": True} + assert set(result["opt_in_adopted"]) == {"alpha", "beta"} diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index d22da3d1756f5..233c2af8d7bbe 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -30,11 +30,17 @@ remove it (or replace it with a real ``sync:*`` scope / config toggle) when sync ships to all users. --- OPT-IN DEFAULT (M1-D, provisional) ----------------------------------- -Nothing syncs unless the user marks a skill for sync via a ``sync`` flag on -the skill's usage sidecar (alongside ``pinned``/``created_by`` in -``.usage.json``). Only agent-created + user-authored skills under -``~/.hermes/skills/`` are eligible; bundled and hub-installed skills are -excluded. +Nothing syncs unless the user marks a skill for sync. The user's local intent +is toggled via ``hermes sync enable/disable`` (a ``sync`` flag on the skill's +``.usage.json`` sidecar, alongside ``pinned``/``created_by``), but the DURABLE, +CROSS-DEVICE opt-in state is a committed ``sync-manifest`` object in the sync +plane (design.md §2.8): a root-level blob in the tree at +``refs/user//HEAD`` recording per-skill ``{name, enabled}``. Push writes +the manifest from local intent; pull reconciles local intent FROM it, so a skill +opted in on one device becomes opted in on the others. The plane manifest is +authoritative; the local flag is just the editable intent. Only agent-created + +user-authored skills under ``~/.hermes/skills/`` are eligible; bundled and +hub-installed skills are excluded. """ from __future__ import annotations @@ -67,6 +73,87 @@ MODE_DIR = "dir" ARTIFACT_TYPE_SKILL = "skill" +# --------------------------------------------------------------------------- +# `sync-manifest` object convention (design.md §2.8). +# +# Per-skill sync opt-in ("this skill syncs / this one does not" — the M1-D +# opt-in state) is CONTENT inside the HSP object model, NOT a device-local flag +# or a mutable preference table. An owner's synced set is a small committed blob +# named ``sync-manifest`` at the ROOT of the tree referenced by +# ``refs/user//HEAD``, recording per-skill ``{name, enabled}``. Toggling +# opt-in is a plain CAS ref update (upload the new manifest blob + root tree + +# commit, then CAS HEAD) — the same primitives push already uses. +# +# This makes opt-in durable and CROSS-DEVICE: device B learns which skills the +# user opted in on device A by reading the manifest on pull, rather than each +# device keeping its own local flag. The ``.usage.json`` ``sync`` flag is kept +# only as the local *intent* the user toggles via ``hermes sync enable`` — it is +# reconciled TO the manifest on pull and FROM it on push; the manifest in the +# plane is authoritative. +# +# MUST match gateway-gateway ``src/sync/manifest.ts`` byte-for-byte (the server +# reads + validates this exact shape). Entry name, ``type`` marker, ``version``, +# and the ``{name, enabled}`` skill shape are the shared contract. +# --------------------------------------------------------------------------- + +SYNC_MANIFEST_ENTRY_NAME = "sync-manifest" +SYNC_MANIFEST_TYPE = "sync-manifest" +SYNC_MANIFEST_VERSION = 1 + + +def build_sync_manifest_bytes(skills: Dict[str, bool]) -> bytes: + """Serialize the per-skill opt-in map into canonical ``sync-manifest`` bytes. + + ``skills`` maps skill name -> enabled. Emits the shape gateway-gateway's + ``parseSyncManifest`` validates: ``{type, version:1, skills:[{name,enabled}]}``. + Skill entries are sorted by name for a stable content address. + """ + manifest = { + "type": SYNC_MANIFEST_TYPE, + "version": SYNC_MANIFEST_VERSION, + "skills": [ + {"name": name, "enabled": bool(enabled)} + for name, enabled in sorted(skills.items()) + ], + } + return canonical_json_bytes(manifest) + + +def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: + """Parse ``sync-manifest`` bytes into ``{name: enabled}``, or ``None`` if the + bytes are not a well-formed manifest. + + Strict (mirrors gateway-gateway ``parseSyncManifest``): an unknown ``type``, + a missing/!=1 ``version``, a non-array ``skills``, or a malformed skill entry + all reject rather than being coerced — a malformed manifest must not be + mistaken for "no skills opted in." + """ + try: + value = json.loads(data.decode("utf-8")) + except Exception: + return None + if not isinstance(value, dict): + return None + if value.get("type") != SYNC_MANIFEST_TYPE: + return None + if value.get("version") != SYNC_MANIFEST_VERSION: + return None + raw_skills = value.get("skills") + if not isinstance(raw_skills, list): + return None + out: Dict[str, bool] = {} + for raw in raw_skills: + if not isinstance(raw, dict): + return None + name = raw.get("name") + enabled = raw.get("enabled") + if not isinstance(name, str) or not name: + return None + if not isinstance(enabled, bool): + return None + out[name] = enabled + return out + # --------------------------------------------------------------------------- # Content addressing (contract §2.1 / OI-5) @@ -674,6 +761,12 @@ def snapshot_profile( The root tree nests category directories: a skill at ``devops/foo`` yields a root entry ``devops`` (tree) containing ``foo`` (tree). Flat skills yield a direct root entry. + + The root tree also carries a ``sync-manifest`` BLOB (design.md §2.8) + recording the per-skill opt-in state, so opt-in is durable + cross-device + rather than a device-local ``.usage.json`` flag. Every skill in + ``skill_names`` is recorded ``enabled: true`` (they ARE the opted-in set); + the manifest is the authoritative record the plane + other devices read. """ from tools.skill_usage import _find_skill_dir @@ -700,12 +793,28 @@ def snapshot_profile( node = node.setdefault(part, {}) node[parts[-1]] = {"__tree__": tree_hash} - root_hash = _build_root_tree(root, objects) + # §2.8 sync-manifest: record the opt-in state (the pushed set = enabled). + # Only skills that actually made it into the tree are recorded, keyed by the + # skill NAME (matching gateway-gateway's manifest shape + the read walk that + # enumerates skill subtrees by name). + manifest_map = {name: True for name in skill_tree_map} + manifest_hash = objects.add( + KIND_BLOB, build_sync_manifest_bytes(manifest_map) + ) + + root_hash = _build_root_tree(root, objects, manifest_hash=manifest_hash) return objects, root_hash, skill_tree_map -def _build_root_tree(node: Dict[str, Any], objects: ObjectSet) -> str: - """Recursively canonicalize the nested root structure into HSP trees.""" +def _build_root_tree( + node: Dict[str, Any], objects: ObjectSet, *, manifest_hash: Optional[str] = None +) -> str: + """Recursively canonicalize the nested root structure into HSP trees. + + ``manifest_hash`` (only passed at the top level) adds a root-level + ``sync-manifest`` BLOB entry (design.md §2.8) alongside the skill subtrees. + It cannot collide with a skill dir (skill entries are trees; this is a blob). + """ entries: List[Dict[str, str]] = [] for name, child in node.items(): if isinstance(child, dict) and "__tree__" in child and len(child) == 1: @@ -717,6 +826,15 @@ def _build_root_tree(node: Dict[str, Any], objects: ObjectSet) -> str: entries.append( {"name": name, "kind": KIND_TREE, "hash": sub_hash, "mode": MODE_DIR} ) + if manifest_hash is not None: + entries.append( + { + "name": SYNC_MANIFEST_ENTRY_NAME, + "kind": KIND_BLOB, + "hash": manifest_hash, + "mode": MODE_FILE, + } + ) entries.sort(key=lambda e: e["name"]) tree_obj = {"type": KIND_TREE, "entries": entries} return objects.add(KIND_TREE, canonical_json_bytes(tree_obj)) @@ -766,6 +884,33 @@ def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, return result +def read_manifest_of_root( + client: "HSPClient", root_tree_hash: str +) -> Optional[Dict[str, bool]]: + """Read the ``sync-manifest`` blob at the root of *root_tree_hash* into + ``{name: enabled}`` (design.md §2.8), or ``None`` if there is no manifest + entry / it is malformed. + + The manifest is a root-level BLOB entry named ``sync-manifest`` (never a + skill subtree). This is how a device learns the cross-device opt-in state + written by another device's push. + """ + try: + tree = client.get_tree_json(root_tree_hash) + except Exception as e: + logger.debug("skills_sync_client: manifest root read failed: %s", e) + return None + for e in tree.get("entries", []): + if e.get("name") == SYNC_MANIFEST_ENTRY_NAME and e.get("kind") == KIND_BLOB: + try: + _kind, data = client.get_object(e["hash"]) + except Exception as ex: + logger.debug("skills_sync_client: manifest blob fetch failed: %s", ex) + return None + return parse_sync_manifest(data) + return None + + def _check_version(caps: Dict[str, Any]) -> None: """Reject an incompatible server major version (contract §1).""" ver = str(caps.get("hsp_version") or "") @@ -1056,10 +1201,34 @@ def pull_skills( root_tree = _root_tree_of_commit(client, head) remote_trees = _skill_trees_of_root(client, root_tree) + # §2.8: reconcile local opt-in intent FROM the plane manifest, so a skill the + # user opted in on another device becomes opted in here too (opt-in is + # cross-device content, not a device-local flag). We only ADOPT enables from + # the manifest for skills present in the remote tree; we never silently + # disable a locally-enabled skill on pull (that stays the user's local call + # until their next push reconciles it). + reconciled_from_manifest: List[str] = [] + remote_manifest = read_manifest_of_root(client, root_tree) + if remote_manifest: + try: + from tools.skill_usage import set_sync, is_curation_eligible, is_sync_enabled + + for sname, enabled in remote_manifest.items(): + if not enabled: + continue + if not is_curation_eligible(sname): + continue + if not is_sync_enabled(sname): + set_sync(sname, True) + reconciled_from_manifest.append(sname) + except Exception as e: + logger.debug("skills_sync_client: manifest opt-in reconcile failed: %s", e) + opted_in = set(_opted_in_rel_paths()) updated = [] for path, tree_hash in remote_trees.items(): - # Opt-in gate on pull: only materialize skills the user chose to sync. + # Opt-in gate on pull: only materialize skills the user chose to sync + # (now including any adopted from the plane manifest above). if opted_in and path not in opted_in: continue dest = _skills_dir() / path @@ -1068,7 +1237,12 @@ def pull_skills( manifest["head"] = head write_sync_manifest(manifest) - return {"ok": True, "head": head, "updated": sorted(updated)} + return { + "ok": True, + "head": head, + "updated": sorted(updated), + "opt_in_adopted": sorted(reconciled_from_manifest), + } def _opted_in_rel_paths() -> List[str]: From 1f33fb2d00ed30ec35ff4610ccd51236f6f4dc7f Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 23 Jul 2026 08:14:53 +1000 Subject: [PATCH 03/36] feat(sync): env-configurable sync defaults + rename local .sync_manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes: 1) Rename the client-local head-bookkeeping file .sync_manifest -> .sync_state (read_sync_state/write_sync_state) to remove the name collision with the §2.8 plane 'sync-manifest' OBJECT. read_sync_state migrates an existing .sync_manifest on first read so no device loses its head record. 2) Make the knobs a Hermes Cloud instance needs env-configurable, so an instance can be set up to use sync BY DEFAULT with no config.yaml edit or per-skill CLI call. Precedence: HERMES_SYNC_* env -> config.yaml sync.* -> built-in default (mirrors the existing HERMES_SYNC_BASE_URL bridge). - HERMES_SYNC_ENABLED -> sync.enabled (master on/off; def off) gated in maybe_push/maybe_pull alongside the dev-gate + base_url. - HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (M1-D policy; def off) opt-out mode: every eligible skill syncs unless usage rec says sync:false; 'your skills follow you with no setup' — the Cloud default. opt-in mode (default) unchanged: only sync:true skills sync. sync_status() + 'hermes sync status' surface feature_enabled/default_opt_in. Cross-repo byte-compat re-verified: client manifest bytes still parse cleanly through gateway-gateway parseSyncManifest. Tests: 41 pass (+7: env precedence, opt-out/opt-in policy, rename migration). --- hermes_cli/main.py | 6 + tests/tools/test_skills_sync_client.py | 78 ++++++++- tools/skills_sync_client.py | 221 ++++++++++++++++++++++--- 3 files changed, 276 insertions(+), 29 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 91d77519d789d..4b80078ef2e46 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4354,6 +4354,12 @@ def cmd_sync(args): "Sync is inert during the dev rollout.", file=sys.stderr, ) + elif not status.get("feature_enabled"): + print( + "\nSync feature is off for this instance (set HERMES_SYNC_ENABLED=1 " + "or config.yaml sync.enabled: true). Sync is inert.", + file=sys.stderr, + ) elif not status.get("base_url"): print( "\nNo sync base URL configured (config.yaml sync.base_url or " diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 815967dc79527..e9f942eda08f3 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -482,9 +482,9 @@ class TestEndToEnd: dev2 = tmp_path / "hermes2" / "skills" dev2.mkdir(parents=True) monkeypatch.setattr(ssc, "_skills_dir", lambda: dev2) - monkeypatch.setattr(ssc, "read_sync_manifest", lambda: {"head": None, "skills": {}}) + monkeypatch.setattr(ssc, "read_sync_state", lambda: {"head": None, "skills": {}}) saved = {} - monkeypatch.setattr(ssc, "write_sync_manifest", lambda d: saved.update(d)) + monkeypatch.setattr(ssc, "write_sync_state", lambda d: saved.update(d)) result = ssc.pull_skills(client, identity=identity) assert result["ok"] is True @@ -657,8 +657,8 @@ class TestSyncManifest: monkeypatch.setattr(su, "is_sync_enabled", lambda name: False) monkeypatch.setattr(su, "set_sync", lambda name, val: adopted.__setitem__(name, val)) # Local head unknown so the pull actually runs. - monkeypatch.setattr(ssc, "read_sync_manifest", lambda: {"head": None, "skills": {}}) - monkeypatch.setattr(ssc, "write_sync_manifest", lambda d: None) + monkeypatch.setattr(ssc, "read_sync_state", lambda: {"head": None, "skills": {}}) + monkeypatch.setattr(ssc, "write_sync_state", lambda d: None) # No local opt-in gate (so materialize isn't the thing under test). monkeypatch.setattr(ssc, "_opted_in_rel_paths", lambda: []) @@ -667,3 +667,73 @@ class TestSyncManifest: # Both skills from the plane manifest were adopted into local opt-in. assert adopted == {"alpha": True, "beta": True} assert set(result["opt_in_adopted"]) == {"alpha", "beta"} + + +# --------------------------------------------------------------------------- +# Env-var configuration (Hermes Cloud "on by default" via environment) +# --------------------------------------------------------------------------- + +class TestEnvConfig: + def test_base_url_env_wins(self, monkeypatch): + monkeypatch.setenv("HERMES_SYNC_BASE_URL", "https://plane.example/") + assert ssc.resolve_sync_base_url() == "https://plane.example" + + def test_feature_enabled_env(self, monkeypatch): + # Default off. + monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False) + assert ssc.sync_feature_enabled() is False + for truthy in ("1", "true", "YES", "on"): + monkeypatch.setenv("HERMES_SYNC_ENABLED", truthy) + assert ssc.sync_feature_enabled() is True + for falsy in ("0", "false", "off"): + monkeypatch.setenv("HERMES_SYNC_ENABLED", falsy) + assert ssc.sync_feature_enabled() is False + + def test_default_opt_in_env(self, monkeypatch): + monkeypatch.delenv("HERMES_SYNC_DEFAULT_OPT_IN", raising=False) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False) + assert ssc.sync_default_opt_in() is False + monkeypatch.setenv("HERMES_SYNC_DEFAULT_OPT_IN", "true") + assert ssc.sync_default_opt_in() is True + + def test_config_yaml_fallback_when_no_env(self, monkeypatch): + monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"sync": {"enabled": True}}, + raising=False, + ) + assert ssc.sync_feature_enabled() is True + + def test_env_overrides_config_yaml(self, monkeypatch): + # Env wins over config.yaml (operator override precedence). + monkeypatch.setenv("HERMES_SYNC_ENABLED", "false") + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"sync": {"enabled": True}}, + raising=False, + ) + assert ssc.sync_feature_enabled() is False + + def test_opt_out_policy_syncs_all_eligible(self, monkeypatch): + # With opt-out on, every eligible skill syncs even with no `sync:true` + # flag; an explicit `sync:false` still excludes. + monkeypatch.setattr(ssc, "sync_default_opt_in", lambda: True) + monkeypatch.setattr(ssc, "_all_local_skill_names", lambda: ["alpha", "beta", "gamma"]) + monkeypatch.setattr(ssc, "is_sync_eligible", lambda n: n in {"alpha", "beta", "gamma"}) + import tools.skill_usage as su + # gamma explicitly opted out; alpha/beta have no flag. + monkeypatch.setattr(su, "load_usage", lambda: {"gamma": {"sync": False}}) + assert ssc.list_synced_skill_names() == ["alpha", "beta"] + + def test_opt_in_policy_requires_flag(self, monkeypatch): + # With opt-out OFF (default opt-in), only explicitly-enabled skills sync. + monkeypatch.setattr(ssc, "sync_default_opt_in", lambda: False) + monkeypatch.setattr(ssc, "is_sync_eligible", lambda n: True) + import tools.skill_usage as su + monkeypatch.setattr( + su, "load_usage", + lambda: {"alpha": {"sync": True}, "beta": {}, "gamma": {"sync": False}}, + ) + assert ssc.list_synced_skill_names() == ["alpha"] diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 233c2af8d7bbe..43a848b91e47a 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -311,6 +311,88 @@ def resolve_sync_base_url() -> Optional[str]: return None +# --------------------------------------------------------------------------- +# Sync feature configuration — env-first, so a Hermes Cloud instance can be set +# up to use sync BY DEFAULT purely through environment variables (no per-user +# config.yaml edit, no per-skill CLI call). Every knob follows the same +# precedence as base_url: the HERMES_SYNC_* env var wins, else config.yaml +# ``sync.*``, else a built-in default. +# +# HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL) +# HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off) +# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (M1-D policy; default false +# = opt-in. Set true to make +# every eligible skill sync +# without per-skill enable — +# the opt-OUT default a Cloud +# deployment wants.) +# --------------------------------------------------------------------------- + +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off", ""} + + +def _parse_bool(value: Any) -> Optional[bool]: + """Parse a config/env bool. Returns None if unrecognized (so callers can + fall through to the next precedence layer). Accepts real bools + strings.""" + if isinstance(value, bool): + return value + if value is None: + return None + s = str(value).strip().lower() + if s in _TRUE: + return True + if s in _FALSE: + return False + return None + + +def _sync_config_bool(env_var: str, config_key: str, *, default: bool) -> bool: + """Resolve a boolean sync knob: ``env_var`` -> ``sync.`` -> default.""" + env_val = _parse_bool(os.getenv(env_var)) + if env_val is not None: + return env_val + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + sync_cfg = cfg.get("sync") or {} + cfg_val = _parse_bool(sync_cfg.get(config_key)) + if cfg_val is not None: + return cfg_val + except Exception as e: + logger.debug("skills_sync_client: config sync.%s read failed: %s", config_key, e) + return default + + +def sync_feature_enabled() -> bool: + """Whether the sync feature is turned on for this instance (env-first). + + ``HERMES_SYNC_ENABLED`` -> ``sync.enabled`` -> False. This is the master + switch a Hermes Cloud deployment sets to opt its instances into sync by + default. It is checked by the gate-and-swallow entrypoints IN ADDITION to + the dev-phase token gate and a configured base URL — all three must hold for + background sync to run. + """ + return _sync_config_bool("HERMES_SYNC_ENABLED", "enabled", default=False) + + +def sync_default_opt_in() -> bool: + """The M1-D default opt-in policy (env-first). + + ``HERMES_SYNC_DEFAULT_OPT_IN`` -> ``sync.default_opt_in`` -> False. + + False (default): opt-IN — a skill syncs only after an explicit + ``hermes sync enable`` (or a plane manifest that opted it in). True: opt-OUT + — every sync-eligible skill is treated as opted in unless explicitly + disabled, which is the "your skills follow you with no setup" default a + Hermes Cloud deployment wants. Per design.md §3.0 M1-D this default is + provisional and expected to flip; exposing it as env config lets the + operator choose per deployment without a protocol change. + """ + return _sync_config_bool("HERMES_SYNC_DEFAULT_OPT_IN", "default_opt_in", default=False) + + # --------------------------------------------------------------------------- # Local skill eligibility + the M1-D opt-in "sync" flag # @@ -348,19 +430,73 @@ def is_sync_eligible(skill_name: str) -> bool: def list_synced_skill_names() -> List[str]: - """Return the names of skills the user has opted into sync (``sync: true``) - AND that remain eligible. Sorted, deduped.""" + """Return the names of skills that should sync, honoring the opt-in policy. + + Two policies (``sync_default_opt_in()``, env-first — see that function): + + - **opt-in (default):** a skill syncs only when its usage record carries + ``sync: true`` AND it is eligible. Nothing syncs by default. + - **opt-out (Hermes Cloud "on by default"):** every *eligible* skill syncs + UNLESS its usage record explicitly carries ``sync: false``. This is what a + deployment sets (via ``HERMES_SYNC_DEFAULT_OPT_IN``) so a user's skills + follow them with no per-skill setup. + + Sorted, deduped. + """ try: from tools.skill_usage import load_usage except Exception: return [] + usage = load_usage() or {} + + if sync_default_opt_in(): + # opt-OUT: all eligible skills except those explicitly turned off. + names = [] + for name in _all_local_skill_names(): + rec = usage.get(name) + if isinstance(rec, dict) and rec.get("sync") is False: + continue # explicit opt-out wins over the deployment default + if is_sync_eligible(name): + names.append(name) + return sorted(set(names)) + + # opt-IN (default): only explicitly-enabled eligible skills. names = [] - for name, rec in (load_usage() or {}).items(): + for name, rec in usage.items(): if isinstance(rec, dict) and rec.get("sync") is True and is_sync_eligible(name): names.append(name) return sorted(set(names)) +def _all_local_skill_names() -> List[str]: + """Best-effort enumeration of every locally-present skill name (used by the + opt-out policy). A skill is any directory under ~/.hermes/skills/ containing + a ``SKILL.md``; the name is its frontmatter ``name`` (falling back to the + directory name). Eligibility (bundled/hub/external exclusion) is applied by + the caller via ``is_sync_eligible``. + """ + names: List[str] = [] + root = _skills_dir() + try: + if not root.exists(): + return [] + for skill_md in root.rglob("SKILL.md"): + if skill_md.is_symlink(): + continue + name: Optional[str] = None + try: + from tools.skill_usage import _read_skill_name + + name = _read_skill_name(skill_md, skill_md.parent.name) + except Exception: + name = skill_md.parent.name + if name: + names.append(name) + except OSError as e: + logger.debug("skills_sync_client: local skill enumeration failed: %s", e) + return sorted(set(names)) + + # --------------------------------------------------------------------------- # Object building -- turn a skill directory into HSP blob/tree/commit objects # @@ -635,26 +771,55 @@ class HSPClient: # --------------------------------------------------------------------------- -# HSP sync manifest (client-local, FULL-digest namespace) +# HSP local sync STATE (client-local head bookkeeping, FULL-digest namespace) # -# Records, per synced skill, the last commit HEAD we pushed/pulled and the -# tree hash of the on-disk content at that point. Distinct from the bundled -# manifest (skills_sync.py, truncated local content_hash namespace). Lives at -# ~/.hermes/skills/.sync_manifest as JSON. +# Records the last commit HEAD we pushed/pulled and, per synced skill, the tree +# hash of the on-disk content at that point. Distinct from the bundled manifest +# (skills_sync.py, truncated local content_hash namespace) AND from the §2.8 +# `sync-manifest` OBJECT in the sync plane (the per-skill opt-in content). This +# is purely local reconciliation bookkeeping. Lives at +# ~/.hermes/skills/.sync_state as JSON. +# +# NOTE: renamed from `.sync_manifest` -> `.sync_state` to remove the collision +# with the §2.8 plane `sync-manifest`. `read_sync_state` migrates an existing +# `.sync_manifest` on first read so no local head record is lost. # --------------------------------------------------------------------------- -def _sync_manifest_path() -> Path: +def _sync_state_path() -> Path: + return _skills_dir() / ".sync_state" + + +def _legacy_sync_state_path() -> Path: return _skills_dir() / ".sync_manifest" -def read_sync_manifest() -> Dict[str, Any]: - """Read the HSP sync manifest. Returns {} on missing/corrupt. +def read_sync_state() -> Dict[str, Any]: + """Read the local HSP sync state. Returns a default on missing/corrupt. Shape: ``{"head": "sha256:...|null", "skills": {name: {tree, commit}}}``. ``head`` is the last profile-root HEAD commit we reconciled with. + + Migrates a legacy ``.sync_manifest`` file (pre-rename) transparently: if the + new ``.sync_state`` is absent but the legacy file exists, it is read and + rewritten to the new path so an existing device keeps its head record. """ - path = _sync_manifest_path() + path = _sync_state_path() if not path.exists(): + legacy = _legacy_sync_state_path() + if legacy.exists(): + try: + data = json.loads(legacy.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("head", None) + data.setdefault("skills", {}) + write_sync_state(data) # migrate to the new path + try: + legacy.unlink() + except OSError: + pass + return data + except (OSError, json.JSONDecodeError) as e: + logger.debug("skills_sync_client: legacy sync state migrate failed: %s", e) return {"head": None, "skills": {}} try: data = json.loads(path.read_text(encoding="utf-8")) @@ -663,18 +828,18 @@ def read_sync_manifest() -> Dict[str, Any]: data.setdefault("skills", {}) return data except (OSError, json.JSONDecodeError) as e: - logger.debug("skills_sync_client: sync manifest read failed: %s", e) + logger.debug("skills_sync_client: sync state read failed: %s", e) return {"head": None, "skills": {}} -def write_sync_manifest(data: Dict[str, Any]) -> None: - """Write the HSP sync manifest atomically. Best-effort.""" +def write_sync_state(data: Dict[str, Any]) -> None: + """Write the local HSP sync state atomically. Best-effort.""" import tempfile - path = _sync_manifest_path() + path = _sync_state_path() try: path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sync_manifest_", suffix=".tmp") + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sync_state_", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False) @@ -688,7 +853,7 @@ def write_sync_manifest(data: Dict[str, Any]) -> None: pass raise except Exception as e: - logger.debug("skills_sync_client: sync manifest write failed: %s", e) + logger.debug("skills_sync_client: sync state write failed: %s", e) # --------------------------------------------------------------------------- @@ -956,7 +1121,7 @@ def push_skills( objects, root_hash, _ = snapshot_profile(skill_names, max_object_bytes=max_bytes) - manifest = read_sync_manifest() + manifest = read_sync_state() base_head = manifest.get("head") # Idempotency: if the profile-root tree is unchanged since our last push, @@ -978,7 +1143,7 @@ def push_skills( client.cas_ref(ref, base_head, commit_hash) manifest["head"] = commit_hash manifest["root"] = root_hash - write_sync_manifest(manifest) + write_sync_state(manifest) return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)} except HSPConflict as conflict: return _resolve_push_conflict( @@ -1095,10 +1260,10 @@ def _resolve_push_conflict( "message": f"merge CAS lost again (head now {c2.actual}); retry sync.", "actual_head": c2.actual, } - manifest = read_sync_manifest() + manifest = read_sync_state() manifest["head"] = merge_commit manifest["root"] = merged_root - write_sync_manifest(manifest) + write_sync_state(manifest) return {"ok": True, "head": merge_commit, "merged": True} @@ -1194,7 +1359,7 @@ def pull_skills( if not head: return {"ok": True, "reason": "no remote HEAD yet", "noop": True} - manifest = read_sync_manifest() + manifest = read_sync_state() if head == manifest.get("head"): return {"ok": True, "reason": "already up to date", "head": head, "noop": True} @@ -1236,7 +1401,7 @@ def pull_skills( updated.append(path) manifest["head"] = head - write_sync_manifest(manifest) + write_sync_state(manifest) return { "ok": True, "head": head, @@ -1271,6 +1436,8 @@ def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[st identity = resolve_identity() if not identity.get("dev_gate_ok"): return None # DEV-PHASE gate: inert without tool_gateway_admin + if not sync_feature_enabled(): + return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): return None if not list_synced_skill_names(): @@ -1289,6 +1456,8 @@ def maybe_pull_skills() -> Optional[Dict[str, Any]]: identity = resolve_identity() if not identity.get("dev_gate_ok"): return None # DEV-PHASE gate: inert without tool_gateway_admin + if not sync_feature_enabled(): + return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): return None return pull_skills(identity=identity) @@ -1302,6 +1471,8 @@ def sync_status() -> Dict[str, Any]: status: Dict[str, Any] = { "dev_gate_ok": False, "logged_in": False, + "feature_enabled": sync_feature_enabled(), + "default_opt_in": sync_default_opt_in(), "base_url": resolve_sync_base_url(), "opted_in_skills": [], "local_head": None, @@ -1318,7 +1489,7 @@ def sync_status() -> Dict[str, Any]: logger.debug("skills_sync_client: sync_status identity failed: %s", e) try: status["opted_in_skills"] = list_synced_skill_names() - status["local_head"] = read_sync_manifest().get("head") + status["local_head"] = read_sync_state().get("head") except Exception: pass return status From ce4a08d6e11927a2358c1460c04ca69c32d51b0e Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 23 Jul 2026 11:50:10 +1000 Subject: [PATCH 04/36] feat(sync): descriptive device names (hostname default, --name, Cloud env seed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit author.device was an opaque uuid4 hex, so the sync console showed a hash per device. Make it human-friendly: - Default: seed new devices from the short hostname + a short random suffix (e.g. bens-macbook-a1b2c3) instead of a bare uuid. Existing .sync_device_id files are honored verbatim (a machine keeps its id) — backward-compatible. - hermes sync device [--name N]: show or set an explicit label (set_device_name(), written to ~/.hermes/skills/.sync_device_id). New commits use it; past commits keep their old label (author.device is immutable). - Hermes Cloud: HERMES_SYNC_DEVICE_NAME env seeds the first-use label so a hosted instance shows a recognizable name with no CLI call. Precedence: explicit .sync_device_id file > HERMES_SYNC_DEVICE_NAME env > hostname default. Env seeds first-use only, then persists, so a later --name still wins locally. Tests: 46 pass (+5: hostname default, file-wins precedence, env first-use seed + persistence, set/trim, empty-name reject). CLI verb smoke-verified end-to-end. --- hermes_cli/main.py | 26 ++++++++++- hermes_cli/subcommands/sync.py | 13 ++++++ tests/tools/test_skills_sync_client.py | 46 +++++++++++++++++++ tools/skills_sync_client.py | 61 +++++++++++++++++++++++--- 4 files changed, 139 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4b80078ef2e46..dfd6890430298 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4313,18 +4313,40 @@ def cmd_sync(args): if sub in {None, ""}: print( - "usage: hermes sync \n" + "usage: hermes sync \n" "\n" " status Show sync gate, opt-in, and head state\n" " pull Pull the owner's HEAD, materialize opted-in skills\n" " push Push opted-in skills to the owner's HEAD\n" " now Reconcile now: pull then push\n" " enable Opt a skill into sync (M1-D opt-in)\n" - " disable Opt a skill out of sync", + " disable Opt a skill out of sync\n" + " device [--name N] Show or set this device's sync label", file=sys.stderr, ) return 1 + if sub == "device": + from tools import skills_sync_client as ssc + + name = getattr(args, "device_name", None) + if name is not None: + try: + stored = ssc.set_device_name(name) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(f"device label set to '{stored}'.") + print( + "New commits from this device will use this label; existing " + "commits keep their previous one.", + file=sys.stderr, + ) + return 0 + # No --name: print the current (creating a default on first use). + print(ssc.stable_device_id()) + return 0 + if sub in {"enable", "disable"}: from tools.skill_usage import set_sync, is_curation_eligible diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index 6473d74588a4a..b6d4d40d20956 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -10,6 +10,7 @@ Commands: hermes sync now -- pull then push (full reconcile) hermes sync enable -- opt a skill into sync (M1-D) hermes sync disable -- opt a skill out of sync + hermes sync device [--name] -- show or set this device's sync label Sync is INERT unless the resolved Nous token carries the DEV-PHASE gate claim (tool_gateway_admin) AND a sync base URL is configured. The commands report @@ -41,4 +42,16 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: disable = sync_sub.add_parser("disable", help="Opt a skill out of sync") disable.add_argument("skill", help="Skill name (frontmatter name / directory name)") + device = sync_sub.add_parser( + "device", + help="Show or set this device's sync label (shown in the sync console)", + ) + device.add_argument( + "--name", + dest="device_name", + default=None, + help="Set a human-friendly label for this device (e.g. \"Ben's Laptop\"). " + "Omit to print the current label.", + ) + sync_parser.set_defaults(func=cmd_sync) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index e9f942eda08f3..2f18d70e08112 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -737,3 +737,49 @@ class TestEnvConfig: lambda: {"alpha": {"sync": True}, "beta": {}, "gamma": {"sync": False}}, ) assert ssc.list_synced_skill_names() == ["alpha"] + + +class TestDeviceName: + def test_default_is_hostname_seeded(self, tmp_path, monkeypatch): + monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path) + monkeypatch.delenv("HERMES_SYNC_DEVICE_NAME", raising=False) + monkeypatch.setattr( + "socket.gethostname", lambda: "bens-macbook.local", raising=False + ) + val = ssc.stable_device_id() + # short hostname + short suffix, NOT a bare 32-char hash + assert val.startswith("bens-macbook-") + assert val != "bens-macbook-" + # persisted + stable across calls + assert (tmp_path / ".sync_device_id").read_text() == val + assert ssc.stable_device_id() == val + + def test_existing_file_wins_over_default_and_env(self, tmp_path, monkeypatch): + monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path) + (tmp_path / ".sync_device_id").write_text("Explicit Name", encoding="utf-8") + monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "cloud-seed") + assert ssc.stable_device_id() == "Explicit Name" + + def test_env_seeds_first_use(self, tmp_path, monkeypatch): + # Hermes Cloud path: HERMES_SYNC_DEVICE_NAME seeds the first-use label. + monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path) + monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "hermes-cloud-ben-1") + assert ssc.stable_device_id() == "hermes-cloud-ben-1" + # persisted so it stays stable even if the env later changes + assert (tmp_path / ".sync_device_id").read_text() == "hermes-cloud-ben-1" + monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "changed") + assert ssc.stable_device_id() == "hermes-cloud-ben-1" + + def test_set_device_name_overwrites(self, tmp_path, monkeypatch): + monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path) + (tmp_path / ".sync_device_id").write_text("old", encoding="utf-8") + stored = ssc.set_device_name(" Ben's Laptop ") + assert stored == "Ben's Laptop" # trimmed + assert ssc.stable_device_id() == "Ben's Laptop" + + def test_set_device_name_rejects_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path) + import pytest + + with pytest.raises(ValueError): + ssc.set_device_name(" ") diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 43a848b91e47a..66682a14785b8 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -603,10 +603,36 @@ def build_commit( return objects.add(KIND_COMMIT, canonical_json_bytes(commit_obj)) +def _default_device_label() -> str: + """A human-friendly default device label: the short hostname plus a short + random suffix for uniqueness (two machines can share a hostname). Falls back + to a bare uuid if the hostname is unavailable/unusable.""" + import socket + import uuid + + suffix = uuid.uuid4().hex[:6] + try: + host = socket.gethostname() or "" + except OSError: + host = "" + # Short hostname (drop domain), strip to a tidy slug; keep it readable. + short = host.split(".")[0].strip() + # Keep only sane chars so the label renders cleanly in the console. + short = "".join(c for c in short if c.isalnum() or c in "-_") or "" + return f"{short}-{suffix}" if short else uuid.uuid4().hex + + def stable_device_id() -> str: - """Return an opaque, stable per-device id for commit ``author.device`` - (contract §2.4 -- advisory, never an auth input). Persisted under - ~/.hermes/skills/.sync_device_id.""" + """Return a stable per-device label for commit ``author.device`` (contract + §2.4 -- advisory, never an auth input). Persisted under + ~/.hermes/skills/.sync_device_id. + + New devices are seeded with a HUMAN-FRIENDLY default (short hostname + a + short random suffix, e.g. ``bens-macbook-a1b2c3``) so the sync console shows + something recognizable instead of an opaque hash. Existing ``.sync_device_id`` + files are honored verbatim (backward-compatible — a machine keeps its id). + Use ``set_device_name()`` / ``hermes sync device --name`` to set an explicit + label.""" path = _skills_dir() / ".sync_device_id" try: if path.exists(): @@ -615,9 +641,17 @@ def stable_device_id() -> str: return val except OSError: pass - import uuid - val = uuid.uuid4().hex + # Hermes Cloud (and any templated deployment) can seed the label + # declaratively via HERMES_SYNC_DEVICE_NAME, so a hosted instance shows a + # recognizable name with no CLI call. Env seeds the FIRST-USE value only; it + # is then persisted, so a later `hermes sync device --name` (or editing the + # file) still wins on that device. An explicit file (above) always wins over + # the env. + import os + + env_name = (os.environ.get("HERMES_SYNC_DEVICE_NAME") or "").strip() + val = env_name if env_name else _default_device_label() try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(val, encoding="utf-8") @@ -626,6 +660,23 @@ def stable_device_id() -> str: return val +def set_device_name(name: str) -> str: + """Set the human-friendly device label used for commit ``author.device``. + + Writes the (trimmed) name to ~/.hermes/skills/.sync_device_id, overwriting + any previous value. The label is advisory metadata only — never an auth + input (contract §2.4) — so any non-empty string is accepted. Returns the + stored value. Raises ValueError on an empty name. + """ + cleaned = (name or "").strip() + if not cleaned: + raise ValueError("device name must be a non-empty string") + path = _skills_dir() / ".sync_device_id" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(cleaned, encoding="utf-8") + return cleaned + + # --------------------------------------------------------------------------- # HSP/1 wire client # From 540836c32fa7e76ab5494096b858945896eb517a Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 23 Jul 2026 11:59:28 +1000 Subject: [PATCH 05/36] fix(sync): register 'sync' in _BUILTIN_SUBCOMMANDS test_startup_plugin_gating::test_builtin_set_covers_every_registered_subcommand failed: 'sync' was a live subcommand but missing from _BUILTIN_SUBCOMMANDS. This pre-existed on the branch (the original sync command was never registered here); CI's registry-completeness guard caught it. Beyond the test, the omission meant 'hermes sync ...' triggered a ~500-650ms plugin-discovery pass it should skip. Add 'sync' to the frozenset. Guard test + sync suites green (84 passed). --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index dfd6890430298..1be79530d57a0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12829,7 +12829,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "project", "proxy", "prompt-size", "send", "sessions", "setup", - "skills", "slack", "status", "tools", "uninstall", "update", + "skills", "slack", "status", "sync", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security", # Help-ish invocations — plugin commands not being listed in # top-level --help is an acceptable trade-off for skipping an From bdef497a5a04f977a487db069e14e6c4d6e6ce22 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 23 Jul 2026 20:02:26 +1000 Subject: [PATCH 06/36] =?UTF-8?q?feat(sync):=20M2=20org-skills=20client=20?= =?UTF-8?q?=E2=80=94=20=5Forg/=20pull,=20hermes=20skills=20propose,=20202?= =?UTF-8?q?=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client leg of M2 org-shared skills (hsp-1-contract.md §11), pairing with gateway-gateway #162 and NAS #768 (both merged). - resolve_org_identity(): org_id + org_role from the token claims. NO org_role claim (personal org — NAS only stamps it for multi-member orgs) => SyncInertError => every org surface is inert; personal M1 sync untouched (contract §11.1 REFINED). org_sync_available() for callers. - pull_org_skills(): materialize the org canonical set (refs/org// HEAD) into ~/.hermes/skills/_org// — fast-forward only, no client merge on the org path (design.md §2.6); read-only mirror by convention (§7.1: a local edit is a personal fork until proposed). maybe_pull_org_ skills() best-effort hook (never raises; inert without the claim). - propose_skill(): snapshot the LOCAL skill dir, splice it into the org HEAD's skill-tree map (per-skill delta, never wholesale replace), upload ?scope=org, CAS the org HEAD. ADMIN => direct merge; MEMBER => server converts to a proposal — cas_ref now surfaces 202 as {proposal_pending: True, proposal_id, ref} (success-shaped, NEVER presented as live). Non-interactive by design for the future automated submitter (Ben's trajectory note). - put_objects(org_scope=True) adds ?scope=org (contract §11.5). - is_sync_eligible(): skills under _org/ are excluded from PERSONAL sync — enterprise content never rides a personal push (§11.11). - CLI: hermes skills propose [-m msg] — prints 'pending admin review' for 202, 'merged' for admin, and a plain 'org sync unavailable' for personal orgs instead of a raw 403. Tests: 56 in the sync client suite (+10 org: identity gate both ways, _org/ personal-sync exclusion, admin direct merge, member 202 w/ HEAD untouched + proposal ref parked + never-merged, splice-not-replace root, pull mirror materialization, no-head noop, org-feature gate, maybe_pull inert). Mock server extended (org feature flag, member-CAS→202). 103 across skills suites. Live CLI smoke: propose --help + personal-org inert path verified. --- hermes_cli/main.py | 28 +++ hermes_cli/subcommands/skills.py | 23 +++ tests/tools/test_skills_sync_client.py | 172 +++++++++++++++- tools/skills_sync_client.py | 274 ++++++++++++++++++++++++- 4 files changed, 490 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3a59e75e8601c..90597526cb7f4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13895,6 +13895,34 @@ def cmd_skills(args): from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) + elif getattr(args, "skills_action", None) == "propose": + # M2 org-shared skills (hsp-1-contract.md §11.5): propose a local + # skill to the org canonical set. 202 => pending review (NEVER shown + # as live); direct merge for admins. Personal orgs have no org + # workflow — say so plainly instead of a raw 403. + from tools import skills_sync_client as ssc + + name = args.name + try: + result = ssc.propose_skill(name, message=args.message) + except ssc.SyncInertError as e: + print(f"org sync unavailable: {e}", file=sys.stderr) + return 1 + except ssc.HSPError as e: + print(f"propose failed: {e}", file=sys.stderr) + return 1 + if result.get("proposal_pending"): + print( + f"proposed '{name}' — pending admin review " + f"(proposal #{result.get('proposal_id')}). Not live for the " + f"org until approved." + ) + else: + print( + f"merged '{name}' into the org set " + f"(head {str(result.get('head', ''))[:19]}…)." + ) + return 0 else: from hermes_cli.skills_hub import skills_command diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index e5f4410fe32e6..b3c23a95a6e0f 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -312,4 +312,27 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: "config", help="Interactive skill configuration — enable/disable individual skills", ) + + # M2 org-shared skills (hsp-1-contract.md §11.5/§11.11): propose a local + # skill's content to the org canonical set. MEMBER → 202 proposal + # (pending admin review); ADMIN/OWNER → direct merge. Only meaningful for + # multi-member orgs — personal orgs have no org workflow (the command + # reports that instead of failing opaquely). + skills_propose = skills_subparsers.add_parser( + "propose", + help="Propose a skill to your org's shared skill set (M2)", + description=( + "Snapshot the local skill and submit it to the org canonical set. " + "An org admin's push merges directly; a member's push becomes a " + "proposal reviewed in the org console. Personal orgs keep simple " + "personal sync and have no proposal workflow." + ), + ) + skills_propose.add_argument("name", help="Skill name to propose") + skills_propose.add_argument( + "-m", + "--message", + default=None, + help="Optional proposal message (defaults to 'propose ')", + ) skills_parser.set_defaults(func=cmd_skills) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 2f18d70e08112..0553dcb6e9328 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -35,6 +35,11 @@ class _MockState: self.hsp_version = "1" self.max_object_bytes = 26214400 self.force_conflict_once = False # inject a 409 on the next CAS + # M2 org behavior (contract §11): advertise the "org" feature and, + # when org_role_admin is False, convert org-HEAD CAS to 202 proposals. + self.org_feature = True + self.org_role_admin = True + self.proposals = [] # [{n, to, base}] def _make_handler(state: _MockState): @@ -59,9 +64,10 @@ def _make_handler(state: _MockState): query = self.path.split("?", 1)[1] if path == "/v1/sync/capabilities": + features = ["personal"] + (["org"] if state.org_feature else []) return self._json(200, { "hsp_version": state.hsp_version, - "features": ["personal"], + "features": features, "max_object_bytes": state.max_object_bytes, "hash_alg": "sha256", "auth": "bearer", @@ -106,11 +112,12 @@ def _make_handler(state: _MockState): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"" + path = self.path.split("?", 1)[0] # e.g. /v1/sync/objects?scope=org - if self.path == "/v1/sync/objects": + if path == "/v1/sync/objects": return self._handle_put_objects(raw) - if self.path.startswith("/v1/sync/refs/"): + if path.startswith("/v1/sync/refs/"): return self._handle_cas(raw) self._json(404, {"error": "unknown"}) @@ -169,6 +176,15 @@ def _make_handler(state: _MockState): body = json.loads(raw.decode("utf-8")) if raw else {} frm = body.get("from") to = body.get("to") + # M2 (contract §11.5): a non-admin member's CAS on an org HEAD is + # accept-always converted to a proposal → 202. + if name.startswith("refs/org/") and not state.org_role_admin: + n = len(state.proposals) + 1 + state.proposals.append({"n": n, "to": to, "base": frm}) + org = name.split("/")[2] + prop_ref = f"refs/org/{org}/proposals/{n}" + state.refs[prop_ref] = to + return self._json(202, {"proposal_id": n, "ref": prop_ref}) if state.force_conflict_once: state.force_conflict_once = False return self._json(409, {"actual": state.refs.get(name, "")}) @@ -783,3 +799,153 @@ class TestDeviceName: with pytest.raises(ValueError): ssc.set_device_name(" ") + + +# --------------------------------------------------------------------------- +# M2 org-shared skills (contract §11): identity gate, pull, propose (202/merge) +# --------------------------------------------------------------------------- + +def _org_identity(role=None, org_id="org-1", owner="owner1"): + claims = {"sub": owner, "org_id": org_id, "tool_gateway_admin": True} + if role is not None: + claims["org_role"] = role + token = _jwt(claims) + return {"api_key": token, "base_url": "http://x", "owner": owner, + "dev_gate_ok": True, "claims": claims, + **({"org_id": org_id, "org_role": role} if role else {})} + + +class TestOrgIdentityGate: + def test_org_identity_requires_role_claim(self, monkeypatch): + # Personal org: NAS stamps NO org_role -> inert, not an error path. + token = _jwt({"sub": "u", "org_id": "org-1"}) + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}) + with pytest.raises(ssc.SyncInertError): + ssc.resolve_org_identity() + assert ssc.org_sync_available() is False + + def test_org_identity_with_role(self, monkeypatch): + token = _jwt({"sub": "u", "org_id": "org-9", "org_role": "MEMBER"}) + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token, "base_url": "https://x"}) + ident = ssc.resolve_org_identity() + assert ident["org_id"] == "org-9" + assert ident["org_role"] == "MEMBER" + assert ssc.org_sync_available() is True + + def test_org_mirror_excluded_from_personal_sync(self, tmp_path, monkeypatch): + # A skill under _org// must never be personal-sync eligible. + skills = tmp_path / "skills" + org_skill = skills / "_org" / "org-1" / "shared-x" + org_skill.mkdir(parents=True) + (org_skill / "SKILL.md").write_text("---\nname: shared-x\n---\n") + monkeypatch.setattr(ssc, "_skills_dir", lambda: skills) + import tools.skill_usage as su + monkeypatch.setattr(su, "is_bundled", lambda n: False) + monkeypatch.setattr(su, "is_hub_installed", lambda n: False) + monkeypatch.setattr(su, "_find_skill_dir", lambda n: org_skill) + import agent.skill_utils as sku + monkeypatch.setattr(sku, "is_external_skill_path", lambda p: False) + assert ssc.is_sync_eligible("shared-x") is False + + +class TestOrgEndToEnd: + def test_admin_propose_merges_directly(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"} + client = ssc.HSPClient(base, identity["api_key"]) + result = ssc.propose_skill("alpha", client, identity=identity) + assert result["ok"] is True + assert result.get("merged") is True + head = state.refs["refs/org/org-1/HEAD"] + assert head == result["head"] + commit = json.loads(state.objects[head][1]) + assert commit["parents"] == [] # first org commit + + def test_member_propose_becomes_202_proposal(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + # Seed an org HEAD as admin first. + admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} + client = ssc.HSPClient(base, identity["api_key"]) + seeded = ssc.propose_skill("alpha", client, identity=admin_ident) + + # Member edits beta and proposes: server converts to 202. + state.org_role_admin = False + (skills / "devops" / "beta" / "SKILL.md").write_text( + "---\nname: beta\n---\nbeta v2 member edit\n", encoding="utf-8" + ) + member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"} + result = ssc.propose_skill("beta", client, identity=member_ident) + assert result["ok"] is True + assert result.get("proposal_pending") is True + assert result["proposal_id"] == 1 + # HEAD untouched; proposal ref parked at the member's commit. + assert state.refs["refs/org/org-1/HEAD"] == seeded["head"] + assert state.refs["refs/org/org-1/proposals/1"] == result["commit"] + # NEVER reported as merged. + assert "merged" not in result + + def test_member_proposal_splices_not_replaces(self, mock_server, synced_env): + # The proposed root must keep the OTHER skills from HEAD (per-skill + # delta, not a wholesale replace). + base, state = mock_server + home, skills, identity = synced_env + admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} + client = ssc.HSPClient(base, identity["api_key"]) + ssc.propose_skill("alpha", client, identity=admin_ident) + ssc.propose_skill("beta", client, identity=admin_ident) + + state.org_role_admin = False + member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"} + result = ssc.propose_skill("alpha", client, identity=member_ident) + # Walk the proposed commit's root: both skills present. + commit = json.loads(state.objects[result["commit"]][1]) + root = json.loads(state.objects[commit["tree"]][1]) + names = {e["name"] for e in root["entries"]} + assert "alpha" in names and "devops" in names + + def test_pull_org_skills_materializes_mirror(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} + client = ssc.HSPClient(base, identity["api_key"]) + ssc.propose_skill("alpha", client, identity=admin_ident) + + result = ssc.pull_org_skills(client, identity=admin_ident) + assert result["ok"] is True + assert "alpha" in result["updated"] + mirrored = skills / "_org" / "org-1" / "alpha" / "SKILL.md" + assert mirrored.exists() + assert mirrored.read_text().endswith("alpha v1\n") + + def test_pull_org_noop_when_no_head(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"} + client = ssc.HSPClient(base, identity["api_key"]) + result = ssc.pull_org_skills(client, identity=ident) + assert result["ok"] is True + assert result["head"] is None + assert result["updated"] == [] + + def test_propose_requires_org_feature(self, mock_server, synced_env): + base, state = mock_server + home, skills, identity = synced_env + state.org_feature = False + ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} + client = ssc.HSPClient(base, identity["api_key"]) + with pytest.raises(ssc.SyncInertError): + ssc.propose_skill("alpha", client, identity=ident) + + def test_maybe_pull_org_inert_without_role(self, monkeypatch): + # Personal org: no org_role claim -> None, never raises. + token = _jwt({"sub": "u", "org_id": "org-1"}) + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", + lambda **kw: {"api_key": token}) + assert ssc.maybe_pull_org_skills() is None diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 66682a14785b8..35a3bf2660e17 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -411,8 +411,10 @@ def is_sync_eligible(skill_name: str) -> bool: """Whether *skill_name* is a candidate for HSP sync (before the opt-in check). Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT - hub-installed, NOT an external-dir skill. Mirrors the exclusion logic used - by the curator (tools/skill_usage.py). + hub-installed, NOT an external-dir skill, and NOT under the org mirror + (``_org/`` — enterprise-managed content pulls from the org HEAD and must + never ride a personal push; contract §11.11 / design.md §7.1). Mirrors the + exclusion logic used by the curator (tools/skill_usage.py). """ try: from tools.skill_usage import is_bundled, is_hub_installed, _find_skill_dir @@ -426,6 +428,12 @@ def is_sync_eligible(skill_name: str) -> bool: return False if is_external_skill_path(skill_dir): return False + try: + rel = skill_dir.resolve().relative_to(_skills_dir().resolve()) + if rel.parts and rel.parts[0] == ORG_DIR_NAME: + return False + except (OSError, ValueError): + pass return True @@ -768,7 +776,12 @@ class HSPClient: # -- write ------------------------------------------------------------- - def put_objects(self, objects: Dict[str, Tuple[str, bytes]]) -> Dict[str, Any]: + def put_objects( + self, + objects: Dict[str, Tuple[str, bytes]], + *, + org_scope: bool = False, + ) -> Dict[str, Any]: """POST /v1/sync/objects (contract §4.2). Batch multi-object upload. Contract §1 requires raw object bytes on the wire (NOT base64-in-JSON), @@ -780,6 +793,10 @@ class HSPClient: the received bytes and rejects the whole batch with 422 on mismatch. Idempotent: a known hash is a no-op ``already_present``. + M2 (contract §11.5): ``org_scope=True`` adds ``?scope=org`` so the + objects land in the ORG scope (org-readable; required before an org + CAS/propose). Gated server-side on the token's org_role claim. + NOTE (framing choice within contract latitude): §4.2 says "length- prefixed OR multipart"; this picks multipart/form-data with (field=hash, filename=type, body=raw-bytes). The server strand must @@ -791,7 +808,10 @@ class HSPClient: for h, (kind, data) in objects.items() ] r = self._session.post( - self._url("objects"), files=files, timeout=self.timeout + self._url("objects"), + files=files, + params={"scope": "org"} if org_scope else None, + timeout=self.timeout, ) if r.status_code == 413: raise HSPError("object too large (413)", status=413) @@ -805,12 +825,22 @@ class HSPClient: """POST /v1/sync/refs/:name -- atomic compare-and-swap (contract §4.4). Raises :class:`HSPConflict` (carrying the actual head) on 409. + + M2 (contract §11.5): a non-admin member's CAS on an org HEAD is never + rejected — the server converts it to a proposal and returns + ``202 {proposal_id, ref}``. Surfaced as + ``{"proposal_pending": True, ...}`` so callers can tell "merged" (200) + from "proposed, awaiting review" (202) without exceptions — a 202 is a + SUCCESS-shaped outcome, never to be presented as live (error table §5). """ r = self._session.post( self._url(f"refs/{name}"), json={"from": from_hash, "to": to_hash}, timeout=self.timeout, ) + if r.status_code == 202: + body = r.json() if r.content else {} + return {"proposal_pending": True, **body} if r.status_code == 409: actual = (r.json() or {}).get("actual", "") raise HSPConflict(actual) @@ -1544,3 +1574,239 @@ def sync_status() -> Dict[str, Any]: except Exception: pass return status + + +# --------------------------------------------------------------------------- +# M2 org-shared skills (hsp-1-contract.md §11) — org pull + propose. +# +# Org skills live under a DISTINCT local namespace, ~/.hermes/skills/_org/ +# (design.md §7.1: enterprise-managed skills are read-only to the runtime; a +# local edit is a personal fork of record until proposed). The org canonical +# set is `refs/org//HEAD` — the SAME object model as personal sync. +# +# PERSONAL-ORG GATE (contract §11.1 REFINED, Ben 2026-07-23): a personal org +# has NO org workflow. The discriminator travels in the token: NAS stamps the +# `org_role` claim ONLY for multi-member orgs. No claim ⇒ every org helper +# here is inert (org_sync_available() False; pull/propose raise SyncInertError) +# and the personal M1 experience is untouched. +# +# TRAJECTORY (Ben): `hermes skills propose` is the M2 MVP surface; proposal is +# intended to become largely automated later (curator/background hooks driving +# the same propose_skill() path). Keep this callable non-interactive. +# --------------------------------------------------------------------------- + +ORG_DIR_NAME = "_org" + + +def resolve_org_identity() -> Dict[str, Any]: + """Resolve identity + org context for org-skill operations. + + Returns ``resolve_identity()``'s dict extended with ``org_id`` and + ``org_role``. Raises :class:`SyncInertError` when the token carries no + ``org_role`` claim (personal org / issuer predates org support) — the + caller should treat org sync as unavailable, NOT as an error. + """ + identity = resolve_identity() + claims = identity.get("claims") or {} + org_id = claims.get("org_id") + org_role = claims.get("org_role") + if not org_id: + raise SyncInertError("token carries no org_id") + if not isinstance(org_role, str) or not org_role: + raise SyncInertError( + "no org_role claim (personal org keeps the simple personal sync; " + "org workflow is multi-member-org only)" + ) + identity["org_id"] = str(org_id) + identity["org_role"] = org_role + return identity + + +def org_sync_available() -> bool: + """True iff this token can see the org-skill surface (multi-member org).""" + try: + resolve_org_identity() + return True + except Exception: + return False + + +def org_head_ref(org_id: str) -> str: + return f"refs/org/{org_id}/HEAD" + + +def _org_dir() -> Path: + """Local mirror root for org skills (read-only by convention §7.1).""" + return _skills_dir() / ORG_DIR_NAME + + +def pull_org_skills( + client: Optional["HSPClient"] = None, + *, + identity: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Pull the org canonical set into ``~/.hermes/skills/_org//``. + + Fast-forward only (design.md §2.6: no client merge on the org path): the + mirror is replaced with the org HEAD's content. Local edits under _org/ + are NOT merged — they are overwritten on pull; a member's change of record + is `propose_skill` (the fork lives in their personal skills, not _org/). + Returns {ok, org_id, head, updated} (updated = skill rel-paths written). + """ + identity = identity or resolve_org_identity() + if "org_id" not in identity: + raise SyncInertError("identity lacks org context; use resolve_org_identity()") + org_id = identity["org_id"] + base_url = resolve_sync_base_url() + if not base_url: + raise SyncInertError("no sync base URL configured") + client = client or HSPClient(base_url, identity["api_key"]) + + caps = client.capabilities() + _check_version(caps) + if "org" not in (caps.get("features") or []): + raise SyncInertError("server does not advertise the 'org' feature") + + refs = client.get_refs(f"refs/org/{org_id}/") + head = next( + (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None + ) + if not head: + return {"ok": True, "org_id": org_id, "head": None, "updated": []} + + root_tree = _root_tree_of_commit(client, head) + skill_trees = _skill_trees_of_root(client, root_tree) + + dest_root = _org_dir() / org_id + updated: List[str] = [] + for rel_path, tree_hash in sorted(skill_trees.items()): + dest = dest_root / PurePosixPath(rel_path) + try: + if dest.exists(): + import shutil + + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + materialize_tree(client, tree_hash, dest) + updated.append(rel_path) + except Exception as e: + logger.warning( + "skills_sync_client: org skill materialize failed for %s: %s", + rel_path, + e, + ) + return {"ok": True, "org_id": org_id, "head": head, "updated": updated} + + +def propose_skill( + skill_name: str, + client: Optional["HSPClient"] = None, + *, + identity: Optional[Dict[str, Any]] = None, + message: Optional[str] = None, +) -> Dict[str, Any]: + """Propose a local skill's current content to the org canonical set. + + Snapshots the LOCAL (personal) skill directory as an org-scoped commit + layered on the current org HEAD tree (splice/replace that one skill + subtree), uploads the objects with ``?scope=org``, then CAS-es the org + HEAD (contract §11.5): + + - ADMIN/OWNER token → the server merges directly → ``{ok, merged: True}``. + - MEMBER token → the server converts to a proposal (202) → + ``{ok, proposal_pending: True, proposal_id, ref}``. NEVER presented as + live/merged. + + Non-interactive by design — an automated submitter (curator hook) drives + this exact function later (Ben's automation trajectory). + """ + identity = identity or resolve_org_identity() + org_id = identity["org_id"] + base_url = resolve_sync_base_url() + if not base_url: + raise SyncInertError("no sync base URL configured") + client = client or HSPClient(base_url, identity["api_key"]) + + caps = client.capabilities() + _check_version(caps) + if "org" not in (caps.get("features") or []): + raise SyncInertError("server does not advertise the 'org' feature") + max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES) + + # Locate the local skill directory (personal namespace, NOT _org/). + rel = _skill_rel_path(skill_name) + if rel is None: + raise HSPError(f"skill '{skill_name}' not found under the skills dir") + skill_dir = _skills_dir() / rel + if not (skill_dir / "SKILL.md").exists(): + raise HSPError(f"skill '{skill_name}' has no SKILL.md") + + # Build the proposed skill tree. + objects = ObjectSet() + skill_tree = build_tree(skill_dir, objects, max_object_bytes=max_bytes) + + # Base = current org HEAD (None for the org's first content). The proposed + # root is HEAD's skill-tree map with this one skill spliced in — proposals + # are per-skill deltas, never a wholesale replace of the org set. + refs = client.get_refs(f"refs/org/{org_id}/") + base_head = next( + (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None + ) + if base_head: + base_root = _root_tree_of_commit(client, base_head) + skill_map = _skill_trees_of_root(client, base_root) + else: + skill_map = {} + skill_map[str(rel)] = skill_tree + + root_hash = _assemble_root_from_skill_trees(client, skill_map, objects) + commit_hash = build_commit( + root_hash, + [base_head] if base_head else [], + owner=identity["owner"], + device=stable_device_id(), + message=message or f"propose {skill_name}", + objects=objects, + ) + + client.put_objects(objects.objects, org_scope=True) + result = client.cas_ref(org_head_ref(org_id), base_head, commit_hash) + + if result.get("proposal_pending"): + return { + "ok": True, + "proposal_pending": True, + "proposal_id": result.get("proposal_id"), + "ref": result.get("ref"), + "commit": commit_hash, + "org_id": org_id, + } + return { + "ok": True, + "merged": True, + "head": result.get("hash", commit_hash), + "commit": commit_hash, + "org_id": org_id, + } + + +def maybe_pull_org_skills() -> Optional[Dict[str, Any]]: + """Best-effort org pull if all gates pass. Never raises; None when inert. + + Gates (all must hold): logged in, org_role claim present (multi-member + org), feature enabled, base URL configured. Personal orgs are inert here + by construction — resolve_org_identity raises SyncInertError without the + claim. + """ + try: + identity = resolve_org_identity() + if not sync_feature_enabled(): + return None + if not resolve_sync_base_url(): + return None + return pull_org_skills(identity=identity) + except Exception as e: + logger.debug( + "skills_sync_client: maybe_pull_org_skills inert/failed: %s", e + ) + return None From 78598d091a8fb18a0b9e803f0222642f3016bbb5 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 24 Jul 2026 11:49:33 +1000 Subject: [PATCH 07/36] =?UTF-8?q?feat(skills):=20org-skill=20namespace=20?= =?UTF-8?q?=E2=80=94=20token-gated=20discovery,=20fail-loud=20collisions,?= =?UTF-8?q?=20provenance=20(M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the agreed design (2026-07-23): org skills are FIRST-CLASS (bare names) with three hard companions. 1. TOKEN-GATED RESOLUTION: _org// mirrors resolve ONLY while marked active. pull_org_skills (which runs only after the token's org_id+org_role verified) writes _org/.active_org; discovery (iter_skill_index_files, _find_skill_dir, snapshot manifest) prunes every other mirror. Leave the org (verified personal token in maybe_pull_org_skills) => marker cleared => org skills stop resolving; offline => marker untouched (grace). Snapshot manifest includes the marker so org switches invalidate the prompt snapshot; _SKILLS_SNAPSHOT_VERSION bumped to 2. 2. FAIL-LOUD COLLISIONS: listing pass unified across snapshot/scan paths; a personal/org name clash flags BOTH entries '[name collision — load via category path]' — neither side silently wins (personal-wins = silent divergence from the org set; org-wins = shadowed personal work). skill_view's existing multi-candidate refusal already rejects the ambiguous bare name. 3. PROVENANCE: org entries list under an org: category with '[org-shared: by ]' tags; skill_view prepends a load-time header (org, author, as-of + read-only/fork-and-propose guidance) INTO the content the model consumes, plus an org_provenance result field. Author comes from the pull-time .org-provenance.json sidecar (HEAD commit author — token-verified at push by the plane's author_mismatch guard, gg #166). 4. READ-ONLY MIRROR: skill_manage patch/edit/delete/write_file refuse org- mirror targets with fork-and-propose guidance; org skills are curation- exempt (is_curation_eligible False — the org HEAD owns them). Tests: 11 new (tests/agent/test_org_skill_namespace.py) covering gating, stale-mirror pruning, org-switch flip, snapshot provenance, listing labels, both-sides collision flags, read-only guard, curation exemption; 448 green across skills/prompt/sync suites. Live E2E (real modules, temp HERMES_HOME, mock plane): merge -> pull -> marker+sidecar -> labeled listing -> exactly-2 collision flags -> load-time header -> edit refused -> marker cleared => org skills vanish, personal survive. --- agent/prompt_builder.py | 101 ++++++++++++-- agent/skill_utils.py | 59 ++++++++ tests/agent/test_org_skill_namespace.py | 176 ++++++++++++++++++++++++ tools/skill_manager_tool.py | 40 ++++++ tools/skill_usage.py | 18 ++- tools/skills_sync_client.py | 87 +++++++++++- tools/skills_tool.py | 64 +++++++++ 7 files changed, 529 insertions(+), 16 deletions(-) create mode 100644 tests/agent/test_org_skill_namespace.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 4ae71cbb3a372..18360baf38c2a 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -19,13 +19,18 @@ from typing import Optional from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( EXCLUDED_SKILL_DIRS, + ORG_ACTIVE_MARKER, + ORG_MIRROR_DIR_NAME, + ORG_PROVENANCE_FILE, SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, get_disabled_skill_names, iter_skill_index_files, + org_id_of_path, parse_frontmatter, + read_active_org_id, skill_matches_environment, skill_matches_platform, skill_matches_platform_list, @@ -1310,7 +1315,9 @@ def drain_truncation_warnings() -> list: _SKILLS_PROMPT_CACHE_MAX = 8 _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict() _SKILLS_PROMPT_CACHE_LOCK = threading.Lock() -_SKILLS_SNAPSHOT_VERSION = 1 +# v2: entries gained org provenance fields (org_id/org_author/rel_dir) for M2 +# org-shared skills; older snapshots are discarded and rebuilt. +_SKILLS_SNAPSHOT_VERSION = 2 def _skills_prompt_snapshot_path() -> Path: @@ -1329,13 +1336,32 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None: def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]: - """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.""" + """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files. + + Org mirrors (M2): only the ACTIVE org's mirror participates, and the + ``.active_org`` marker itself is included — so switching/leaving an org + invalidates the snapshot even when no SKILL.md changed. + """ manifest: dict[str, list[int]] = {} skills_dir_str = str(skills_dir) base = os.path.join(skills_dir_str, "") prefix_len = len(base) + active_org = read_active_org_id(skills_dir) + org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) + marker_path = os.path.join(org_root, ORG_ACTIVE_MARKER) + try: + st = os.stat(marker_path) + manifest[ORG_MIRROR_DIR_NAME + "/" + ORG_ACTIVE_MARKER] = [ + int(st.st_mtime), int(st.st_size), + ] + except OSError: + pass for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files + if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: + dirs.remove(ORG_MIRROR_DIR_NAME) + elif root == org_root: + dirs[:] = [d for d in dirs if d == active_org] dirs[:] = [ d for d in dirs @@ -1400,6 +1426,15 @@ def _build_snapshot_entry( """Build a serialisable metadata dict for one skill.""" rel_path = skill_file.relative_to(skills_dir) parts = rel_path.parts + + # M2 org mirror: strip the `_org//` prefix so category/name derive + # from the path WITHIN the mirror (same shape the org tree was built + # from), and record provenance for labeling + fail-loud collisions. + org_id: str | None = None + if len(parts) >= 3 and parts[0] == ORG_MIRROR_DIR_NAME: + org_id = parts[1] + parts = parts[2:] + if len(parts) >= 2: skill_name = parts[-2] category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0] @@ -1411,7 +1446,7 @@ def _build_snapshot_entry( if isinstance(platforms, str): platforms = [platforms] - return { + entry = { "skill_name": skill_name, "category": category, "frontmatter_name": str(frontmatter.get("name", skill_name)), @@ -1419,6 +1454,22 @@ def _build_snapshot_entry( "platforms": [str(p).strip() for p in platforms if str(p).strip()], "conditions": extract_skill_conditions(frontmatter), } + if org_id: + entry["org_id"] = org_id + # Author from the pull-time provenance sidecar (token-verified at + # push by the plane's author_mismatch guard). Best-effort. + try: + import json as _json + + prov_path = ( + skills_dir / ORG_MIRROR_DIR_NAME / org_id / ORG_PROVENANCE_FILE + ) + prov = _json.loads(prov_path.read_text(encoding="utf-8")) + device = str(prov.get("author_device") or "") + entry["org_author"] = device or str(prov.get("author_user_id") or "") + except Exception: + entry["org_author"] = "" + return entry # ========================================================================= @@ -1554,6 +1605,10 @@ def build_skills_system_prompt( skills_by_category: dict[str, list[tuple[str, str]]] = {} category_descriptions: dict[str, str] = {} + # Unified visible-entry list (both paths) so the org labeling + + # fail-loud collision pass below runs identically for snapshot and scan. + visible_entries: list[dict] = [] + skill_entries: list[dict] = [] if snapshot is not None: # Fast path: use pre-parsed metadata from disk @@ -1561,7 +1616,6 @@ def build_skills_system_prompt( if not isinstance(entry, dict): continue skill_name = entry.get("skill_name") or "" - category = entry.get("category") or "general" frontmatter_name = entry.get("frontmatter_name") or skill_name platforms = entry.get("platforms") or [] if not skill_matches_platform_list(platforms): @@ -1574,16 +1628,13 @@ def build_skills_system_prompt( available_toolsets, ): continue - skills_by_category.setdefault(category, []).append( - (frontmatter_name, entry.get("description", "")) - ) + visible_entries.append(entry) category_descriptions = { str(k): str(v) for k, v in (snapshot.get("category_descriptions") or {}).items() } else: # Cold path: full filesystem scan + write snapshot for next time - skill_entries: list[dict] = [] for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"): is_compatible, frontmatter, desc = _parse_skill_file(skill_file) entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc) @@ -1599,10 +1650,38 @@ def build_skills_system_prompt( available_toolsets, ): continue - skills_by_category.setdefault(entry["category"], []).append( - (entry["frontmatter_name"], entry["description"]) - ) + visible_entries.append(entry) + # ── M2 org labeling + FAIL-LOUD collisions ───────────────────────── + # An org skill lists with an explicit provenance tag. When a personal and + # an org skill share a name, NEITHER silently wins: both list qualified + # (personal keeps the bare name is the wrong default — silent divergence + # from the org set; org winning silently shadows the user's own work) — + # so both entries carry a [name collision] flag and skill_view refuses + # the ambiguous bare name (its existing multi-candidate guard). + name_owners: dict[str, set[str]] = {} + for entry in visible_entries: + fm = entry.get("frontmatter_name") or entry.get("skill_name") or "" + kind = "org" if entry.get("org_id") else "personal" + name_owners.setdefault(fm, set()).add(kind) + for entry in visible_entries: + fm = entry.get("frontmatter_name") or entry.get("skill_name") or "" + desc = entry.get("description", "") + org_id = entry.get("org_id") + collided = len(name_owners.get(fm, set())) > 1 + if org_id: + author = entry.get("org_author") or "" + tag = f"[org-shared{': by ' + author if author else ''}]" + desc = f"{tag} {desc}".strip() + category = f"org:{org_id}" + else: + category = entry.get("category") or "general" + if collided: + desc = f"[name collision — also exists {'personally' if org_id else 'in your org'}; load via category path] {desc}".strip() + skills_by_category.setdefault(category, []).append((fm, desc)) + + if snapshot is None: + # (continuation of the cold path below: category descriptions + write) # Read category-level DESCRIPTION.md files for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"): try: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index f96238b9bd959..2f41310f1521c 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -49,6 +49,52 @@ EXCLUDED_SKILL_DIRS = frozenset( # archive workflow preserves a complete old skill package under references/. SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) +# ── M2 org-shared skills (hsp-1-contract.md §11) ─────────────────────────── +# Org mirrors live under ~/.hermes/skills/_org//. Resolution is +# TOKEN-GATED via a marker file the sync client writes after verifying the +# token (skills_sync_client.pull_org_skills): only the marked org's mirror is +# scanned. No marker ⇒ no org skills load. The marker is plain data (org_id +# string) so this module stays import-light; the VERIFICATION lives in the +# sync client, which is the only writer. Offline grace: the marker persists, +# so already-pulled org skills keep working without connectivity; a VERIFIED +# org change (or personal-org token) rewrites/removes it. + +ORG_MIRROR_DIR_NAME = "_org" +ORG_ACTIVE_MARKER = ".active_org" +ORG_PROVENANCE_FILE = ".org-provenance.json" + + +def read_active_org_id(skills_dir: Path) -> Optional[str]: + """The org id whose mirror may resolve, or None (no org skills load).""" + try: + marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER + if not marker.exists(): + return None + val = marker.read_text(encoding="utf-8").strip() + return val or None + except OSError: + return None + + +def is_org_mirror_path(path, skills_dir: Path) -> bool: + """True when *path* is inside the org mirror (``_org/``).""" + try: + rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) + except (OSError, ValueError): + return False + return bool(rel.parts) and rel.parts[0] == ORG_MIRROR_DIR_NAME + + +def org_id_of_path(path, skills_dir: Path) -> Optional[str]: + """The ```` segment for a path under ``_org//...``.""" + try: + rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) + except (OSError, ValueError): + return None + if len(rel.parts) >= 2 and rel.parts[0] == ORG_MIRROR_DIR_NAME: + return rel.parts[1] + return None + def is_excluded_skill_path(path) -> bool: """True if *path* should be skipped by active skill scanners. @@ -802,11 +848,24 @@ def iter_skill_index_files(skills_dir: Path, filename: str): scripts) can contain arbitrary markdown and even archived package ``SKILL.md`` files, but they are progressive-disclosure data loaded through ``skill_view(..., file_path=...)`` rather than active skill roots. + + M2 org mirrors (``_org/``): TOKEN-GATED resolution. Only the active org's + subdir (per the sync-client-written ``.active_org`` marker) is walked; + every other ``_org//`` (stale mirror from a previous org, or no + marker at all) is pruned — leave an org and its skills stop resolving, + without any manual cleanup. """ skills_dir_str = str(skills_dir) + active_org = read_active_org_id(skills_dir) + org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) matches: list[str] = [] for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files + if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: + dirs.remove(ORG_MIRROR_DIR_NAME) + elif root == org_root: + # Inside _org/: descend ONLY into the active org's mirror. + dirs[:] = [d for d in dirs if d == active_org] dirs[:] = [ d for d in dirs diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py new file mode 100644 index 0000000000000..a37a555ee0077 --- /dev/null +++ b/tests/agent/test_org_skill_namespace.py @@ -0,0 +1,176 @@ +"""M2 org-skill namespace: token-gated resolution, provenance, collisions. + +Covers the design agreed 2026-07-23 (bare-name first-class org skills): + 1. TOKEN-GATED discovery — only the `.active_org`-marked mirror resolves; + stale mirrors and marker-less trees never load. + 2. Fail-loud collisions — a personal/org name clash lists BOTH sides flagged; + skill_view's existing multi-candidate guard refuses the bare name. + 3. Load-time provenance header — org skill content announces org + author. + 4. Org mirrors are read-only (skill_manage guards) and curation-exempt. +""" + +import json + +import pytest + +from agent import skill_utils as sku +from agent.prompt_builder import _build_snapshot_entry + + +def _mk_skill(root, rel, name=None, body="# body\n"): + d = root + for part in rel.split("/"): + d = d / part + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name or rel.split('/')[-1]}\ndescription: d\n---\n{body}", + encoding="utf-8", + ) + return d + + +def _mark_active(skills, org_id): + org_root = skills / sku.ORG_MIRROR_DIR_NAME + org_root.mkdir(parents=True, exist_ok=True) + (org_root / sku.ORG_ACTIVE_MARKER).write_text(org_id, encoding="utf-8") + + +class TestTokenGatedDiscovery: + def test_no_marker_no_org_skills(self, tmp_path): + skills = tmp_path / "skills" + _mk_skill(skills, "personal-a") + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")] + assert "personal-a" in found + assert "shared-x" not in found # unmarked mirror never resolves + + def test_marker_gates_to_active_org_only(self, tmp_path): + skills = tmp_path / "skills" + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-OLD/stale-y", name="stale-y") + _mark_active(skills, "org-1") + found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")] + assert "shared-x" in found + assert "stale-y" not in found # stale mirror pruned at resolution + + def test_switching_org_flips_resolution(self, tmp_path): + skills = tmp_path / "skills" + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-2/other-z", name="other-z") + _mark_active(skills, "org-2") + found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")] + assert found and "other-z" in found and "shared-x" not in found + + def test_helpers(self, tmp_path): + skills = tmp_path / "skills" + d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-9/cat/sk", name="sk") + assert sku.is_org_mirror_path(d, skills) is True + assert sku.org_id_of_path(d, skills) == "org-9" + p = _mk_skill(skills, "plain") + assert sku.is_org_mirror_path(p, skills) is False + assert sku.read_active_org_id(skills) is None + _mark_active(skills, "org-9") + assert sku.read_active_org_id(skills) == "org-9" + + +class TestSnapshotEntryProvenance: + def test_org_entry_strips_prefix_and_carries_provenance(self, tmp_path): + skills = tmp_path / "skills" + d = _mk_skill( + skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/devops/beta", name="beta" + ) + (skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text( + json.dumps( + {"author_device": "bens-macbook-a1b2c3", "author_user_id": "u1"} + ), + encoding="utf-8", + ) + entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d") + assert entry["org_id"] == "org-1" + assert entry["org_author"] == "bens-macbook-a1b2c3" + # Category derives from the path WITHIN the mirror, not _org/org-1/... + assert entry["category"] == "devops" + assert entry["skill_name"] == "beta" + + def test_personal_entry_unchanged(self, tmp_path): + skills = tmp_path / "skills" + d = _mk_skill(skills, "devops/beta", name="beta") + entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d") + assert "org_id" not in entry + assert entry["category"] == "devops" + + +class TestListingCollisionsAndLabels: + def _render(self, tmp_path, monkeypatch): + from agent import prompt_builder as pb + + skills = tmp_path / "skills" + skills.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(pb, "get_skills_dir", lambda: skills, raising=True) + monkeypatch.setattr( + pb, "get_all_skills_dirs", lambda: [skills], raising=True + ) + monkeypatch.setattr(pb, "get_disabled_skill_names", lambda *a, **k: set()) + monkeypatch.setattr( + pb, "_skills_prompt_snapshot_path", lambda: tmp_path / "snap.json" + ) + pb.clear_skills_system_prompt_cache() + return skills, pb + + def test_org_skill_listed_with_provenance_tag(self, tmp_path, monkeypatch): + skills, pb = self._render(tmp_path, monkeypatch) + _mk_skill(skills, "personal-a") + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + (skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text( + json.dumps({"author_device": "bens-macbook"}), encoding="utf-8" + ) + _mark_active(skills, "org-1") + out = pb.build_skills_system_prompt() + assert "org:org-1" in out + assert "[org-shared: by bens-macbook]" in out + assert "personal-a" in out + + def test_collision_flags_both_sides(self, tmp_path, monkeypatch): + skills, pb = self._render(tmp_path, monkeypatch) + _mk_skill(skills, "k8s-debug", body="personal version\n") + _mk_skill( + skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/k8s-debug", name="k8s-debug" + ) + _mark_active(skills, "org-1") + out = pb.build_skills_system_prompt() + # BOTH entries flagged — neither silently wins. + assert out.count("[name collision") == 2 + + def test_no_collision_flag_when_unique(self, tmp_path, monkeypatch): + skills, pb = self._render(tmp_path, monkeypatch) + _mk_skill(skills, "personal-a") + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + _mark_active(skills, "org-1") + out = pb.build_skills_system_prompt() + assert "[name collision" not in out + + +class TestOrgMirrorReadOnly: + def test_skill_manage_patch_refuses_org_mirror(self, tmp_path, monkeypatch): + from tools import skill_manager_tool as smt + + skills = tmp_path / "skills" + _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + _mark_active(skills, "org-1") + monkeypatch.setattr(smt, "_skills_dir", lambda: skills) + from agent import skill_utils as _sku + monkeypatch.setattr( + _sku, "get_all_skills_dirs", lambda: [skills], raising=True + ) + result = smt._patch_skill("shared-x", "body", "hacked") + assert result["success"] is False + assert "ORG-SHARED" in result["error"] + assert "propose" in result["error"] + + def test_curation_exempt(self, tmp_path, monkeypatch): + from tools import skill_usage as su + + skills = tmp_path / "skills" + d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + monkeypatch.setattr(su, "_skills_dir", lambda: skills) + assert su.is_curation_eligible("shared-x", d) is False diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index db984cc9e3b5d..ffb88deb33773 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -622,6 +622,34 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]: return None +def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optional[Dict[str, Any]]: + """Refuse writes to org-mirror skills (M2, contract §11.11 / design §7.1). + + The ``_org/`` mirror is materialized FROM the org HEAD and overwritten on + every pull — a local edit would be silently lost AND would misrepresent + admin-approved shared content. The change path is: fork into a personal + skill, edit, then ``hermes skills propose``. + """ + try: + from agent.skill_utils import is_org_mirror_path + + if is_org_mirror_path(skill_path, _skills_dir()): + return { + "success": False, + "error": ( + f"Refusing {action} for '{name}': it is an ORG-SHARED " + "skill (read-only mirror of your org's approved set; " + "local edits are overwritten on every org pull). To " + "change it: copy it to a personal skill, edit that, then " + "`hermes skills propose ` so an org admin can " + "review and approve." + ), + } + except Exception: + logger.debug("org mirror guard lookup failed for %s", name, exc_info=True) + return None + + def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]: """Look for ``name`` under SKILL.md across OTHER Hermes profiles. @@ -891,6 +919,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: existing = _find_skill(name) if not existing: return {"success": False, "error": _skill_not_found_error(name)} + org_guard = _org_mirror_write_guard(name, existing["path"], "edit") + if org_guard: + return org_guard guard = _background_review_write_guard(name, existing["path"], "edit") if guard: return guard @@ -953,6 +984,9 @@ def _patch_skill( return {"success": False, "error": _skill_not_found_error(name)} skill_dir = existing["path"] + org_guard = _org_mirror_write_guard(name, skill_dir, "patch") + if org_guard: + return org_guard guard = _background_review_write_guard(name, skill_dir, "patch") if guard: return guard @@ -1059,6 +1093,9 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A existing = _find_skill(name) if not existing: return {"success": False, "error": _skill_not_found_error(name)} + org_guard = _org_mirror_write_guard(name, existing["path"], "delete") + if org_guard: + return org_guard guard = _background_review_write_guard(name, existing["path"], "delete") if guard: return guard @@ -1176,6 +1213,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: existing = _find_skill(name) if not existing: return {"success": False, "error": _skill_not_found_error(name, " Create it first with action='create'.")} + org_guard = _org_mirror_write_guard(name, existing["path"], "write_file") + if org_guard: + return org_guard guard = _background_review_write_guard(name, existing["path"], "write_file") if guard: return guard diff --git a/tools/skill_usage.py b/tools/skill_usage.py index b38f64f2afbec..8c0fc848a47a4 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -450,12 +450,18 @@ def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> Agent-created skills are always eligible. Bundled built-ins become eligible only when ``curator.prune_builtins`` is enabled. Hub-installed and external skill-dir skills are NEVER eligible — they have an external upstream owner. + Org-mirror skills (``_org/``) are NEVER eligible — the org HEAD owns them; + curation happens via propose → approve, not local archive/consolidate. Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of any flag — they back load-bearing UX and must never be archived or consolidated. """ + from agent.skill_utils import is_org_mirror_path + if skill_path is not None and is_external_skill_path(skill_path): return False + if skill_path is not None and is_org_mirror_path(skill_path, _skills_dir()): + return False if is_protected_builtin(skill_name): return False if is_hub_installed(skill_name): @@ -464,6 +470,8 @@ def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> return _prune_builtins_enabled() local_dir = _find_skill_dir(skill_name) if local_dir is not None: + if is_org_mirror_path(local_dir, _skills_dir()): + return False return not is_external_skill_path(local_dir) if _find_external_skill_dir(skill_name) is not None: return False @@ -853,14 +861,16 @@ def _find_skill_dir(skill_name: str) -> Optional[Path]: """Locate the directory for a skill by its frontmatter `name:` field. Handles both flat (~/.hermes/skills//SKILL.md) and category-nested - (~/.hermes/skills///SKILL.md) layouts. + (~/.hermes/skills///SKILL.md) layouts. Uses the gated + index iterator so M2 org mirrors resolve ONLY for the active org + (stale ``_org//`` trees never match). """ base = _skills_dir() if not base.exists(): return None - for skill_md in base.rglob("SKILL.md"): - if is_excluded_skill_path(skill_md): - continue + from agent.skill_utils import iter_skill_index_files + + for skill_md in iter_skill_index_files(base, "SKILL.md"): if is_external_skill_path(skill_md): continue if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name: diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 35a3bf2660e17..6b36463aa5b64 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -1671,10 +1671,17 @@ def pull_org_skills( head = next( (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None ) + # TOKEN-GATED resolution marker (agent/skill_utils.read_active_org_id): + # written HERE because this function only runs after resolve_org_identity + # verified the token's org_id + org_role. Discovery scans only the marked + # org's mirror, so a stale mirror from a previous org stops resolving the + # moment a pull runs under a different org — no manual cleanup. + _write_active_org_marker(org_id) if not head: return {"ok": True, "org_id": org_id, "head": None, "updated": []} - root_tree = _root_tree_of_commit(client, head) + head_commit = client.get_commit_json(head) + root_tree = head_commit["tree"] skill_trees = _skill_trees_of_root(client, root_tree) dest_root = _org_dir() / org_id @@ -1695,9 +1702,49 @@ def pull_org_skills( rel_path, e, ) + # Provenance sidecar for the load-time header (skill_view): the HEAD + # commit's author is TOKEN-VERIFIED at push time by the plane + # (author_mismatch guard, gateway-gateway #166) — trustworthy to display. + _write_org_provenance( + org_id, + { + "org_id": org_id, + "head": head, + "author_user_id": (head_commit.get("author") or {}).get("owner", ""), + "author_device": (head_commit.get("author") or {}).get("device", ""), + "ts": head_commit.get("ts", ""), + "skills": updated, + }, + ) return {"ok": True, "org_id": org_id, "head": head, "updated": updated} +def _write_active_org_marker(org_id: str) -> None: + """Record which org's mirror may resolve (best-effort, never raises).""" + try: + from agent.skill_utils import ORG_ACTIVE_MARKER + + root = _org_dir() + root.mkdir(parents=True, exist_ok=True) + (root / ORG_ACTIVE_MARKER).write_text(org_id, encoding="utf-8") + except Exception as e: + logger.debug("skills_sync_client: active-org marker write failed: %s", e) + + +def _write_org_provenance(org_id: str, data: Dict[str, Any]) -> None: + """Persist the org HEAD provenance sidecar (best-effort, never raises).""" + try: + from agent.skill_utils import ORG_PROVENANCE_FILE + + dest = _org_dir() / org_id + dest.mkdir(parents=True, exist_ok=True) + (dest / ORG_PROVENANCE_FILE).write_text( + json.dumps(data, indent=2), encoding="utf-8" + ) + except Exception as e: + logger.debug("skills_sync_client: org provenance write failed: %s", e) + + def propose_skill( skill_name: str, client: Optional["HSPClient"] = None, @@ -1797,9 +1844,31 @@ def maybe_pull_org_skills() -> Optional[Dict[str, Any]]: org), feature enabled, base URL configured. Personal orgs are inert here by construction — resolve_org_identity raises SyncInertError without the claim. + + Marker hygiene: when the token VERIFIABLY lacks the org claim (logged in, + personal org / left the org), the active-org marker is cleared so + previously-mirrored org skills stop resolving. When we simply cannot + resolve identity (offline, logged out), the marker is left alone — + offline grace keeps already-pulled org skills working. """ try: identity = resolve_org_identity() + except SyncInertError: + # Distinguish "verifiably personal/left-org" from "can't tell". + try: + base_identity = resolve_identity() + claims = base_identity.get("claims") or {} + if not claims.get("org_role"): + _clear_active_org_marker() + except Exception: + pass # offline/logged out — keep offline grace + return None + except Exception as e: + logger.debug( + "skills_sync_client: maybe_pull_org_skills inert/failed: %s", e + ) + return None + try: if not sync_feature_enabled(): return None if not resolve_sync_base_url(): @@ -1810,3 +1879,19 @@ def maybe_pull_org_skills() -> Optional[Dict[str, Any]]: "skills_sync_client: maybe_pull_org_skills inert/failed: %s", e ) return None + + +def _clear_active_org_marker() -> None: + """Remove the active-org marker (org skills stop resolving).""" + try: + from agent.skill_utils import ORG_ACTIVE_MARKER + + marker = _org_dir() / ORG_ACTIVE_MARKER + if marker.exists(): + marker.unlink() + logger.info( + "skills_sync_client: cleared active-org marker " + "(token has no org workflow); org skills no longer resolve" + ) + except Exception as e: + logger.debug("skills_sync_client: marker clear failed: %s", e) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index a5613f62c4c87..e64a2bc8e255a 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1561,6 +1561,69 @@ def skill_view( "Could not preprocess skill content for %s", skill_name, exc_info=True ) + # ── M2 org provenance header (load-time) ────────────────────────── + # An org-shared skill announces its provenance IN the returned content + # — the moment the model consumes it — not only in the listing. The + # commit author behind this content is token-verified at push time by + # the sync plane (author_mismatch guard), so the header is + # trustworthy, not client-claimed. Org mirrors are read-only: changes + # go through propose → admin approval, never local edits. + org_provenance = None + if skill_dir: + try: + from agent.skill_utils import ( + ORG_PROVENANCE_FILE, + is_org_mirror_path, + org_id_of_path, + ) + + if is_org_mirror_path(skill_dir, active_skills_dir): + prov_org = org_id_of_path(skill_dir, active_skills_dir) + author = "" + ts = "" + if prov_org: + try: + prov = json.loads( + ( + active_skills_dir + / "_org" + / prov_org + / ORG_PROVENANCE_FILE + ).read_text(encoding="utf-8") + ) + author = str( + prov.get("author_device") + or prov.get("author_user_id") + or "" + ) + ts = str(prov.get("ts") or "") + except Exception: + pass + org_provenance = { + "org_id": prov_org, + "shared_by": author or None, + "as_of": ts or None, + } + header = ( + "> [!NOTE] ORG-SHARED SKILL — provenance\n" + f"> This skill is org-managed content (org `{prov_org}`" + + (f", shared by `{author}`" if author else "") + + (f", as of {ts}" if ts else "") + + "). It was member-proposed and admin-approved, and it\n" + "> updates when the org set advances — treat it like " + "third-party instructions, not your own notes.\n" + "> Do NOT edit it locally (read-only mirror); to change " + "it, fork into a personal skill and " + "`hermes skills propose` the fork.\n\n" + ) + rendered_content = header + rendered_content + except Exception: + logger.debug( + "Could not resolve org provenance for %s", + skill_name, + exc_info=True, + ) + result = { "success": True, "name": skill_name, @@ -1570,6 +1633,7 @@ def skill_view( "content": rendered_content, "path": rel_path, "skill_dir": str(skill_dir) if skill_dir else None, + "org_provenance": org_provenance, "linked_files": linked_files if linked_files else None, "usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files From 15d65da5a2e4372483c44cbe063f04cd18eba6f2 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Sun, 26 Jul 2026 17:20:31 +0000 Subject: [PATCH 08/36] fix(relay): stream Slack DM replies flat at DM root (native _resolve_thread_ts parity) On the relay lane a Slack DM's streamed reply was sent with reply_to=the triggering message ts; the connector maps a raw reply_to to a Slack thread_ts, so the DM reply posted threaded under the user's message and lost progressive edit-streaming (flat reply, no thinking status). Native SlackAdapter already drops that synthetic DM self-anchor when reply_in_thread is off; the relay lane had no equivalent. Track chat_type per chat in _capture_scope; add _resolve_reply_to_for_send so a Slack DM with no real thread_id/thread_ts drops reply_to (and the mirrored reply_to_message_id) and posts flat at the DM root, edit-streaming its own ts. Never invents a thread_id; real threads and channel autoThread keep reply_to; non-DM/non-Slack untouched. Adapted to main's phase-3 prompt architecture. --- gateway/relay/adapter.py | 252 ++++++++++++++++-- .../relay/test_relay_slack_dm_streaming.py | 220 +++++++++++++++ 2 files changed, 444 insertions(+), 28 deletions(-) create mode 100644 tests/gateway/relay/test_relay_slack_dm_streaming.py diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b149539cee50a..b544748208a87 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -72,6 +72,16 @@ class RelayAdapter(BasePlatformAdapter): # recipient's author binding; we re-attach this user_id as # metadata.user_id on the outbound action so it can. See _capture_scope. self._dm_user_by_chat: Dict[str, str] = {} + # chat_id -> chat_type (e.g. "dm", "channel", "group") learned from the + # inbound event. Used to reproduce native Slack's synthetic-DM-thread + # suppression on the relay lane: a DM streaming reply carries + # reply_to= as its edit anchor, but the connector + # maps a raw reply_to to a Slack thread_ts — so a plain DM reply would be + # threaded UNDER the user's message (and lose progressive edit streaming) + # instead of posting flat at the DM root. Native SlackAdapter drops that + # synthetic reply_to in _resolve_thread_ts; the relay lane needs the same + # disambiguation, and it needs the chat_type to know a chat is a DM. + self._chat_type_by_chat: Dict[str, str] = {} # chat_id -> the UNDERLYING platform (e.g. "discord", "telegram") this # chat belongs to (Phase 1.5 multi-platform-per-agent). One relay adapter # fronts N platforms on one WS; an outbound reply must egress through the @@ -344,10 +354,19 @@ class RelayAdapter(BasePlatformAdapter): scope = getattr(src, "scope_id", None) if scope: self._scope_by_chat[str(chat)] = str(scope) + # Remember the chat_type so send() can suppress the synthetic-DM + # thread anchor on Slack (native _resolve_thread_ts parity). send() + # only receives a chat_id, so it needs this per-chat cache to know a + # chat is a DM. + chat_type = getattr(src, "chat_type", None) + if chat_type: + self._chat_type_by_chat[str(chat)] = str(chat_type) except Exception: # noqa: BLE001 - scope tracking must never break inbound pass - def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: + def _with_scope( + self, chat_id: str, metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: """Ensure the outbound metadata carries the discriminator(s) the connector's egress guard needs to resolve the owning tenant. @@ -506,16 +525,26 @@ class RelayAdapter(BasePlatformAdapter): else: text = "" member = payload.get("member") or {} - user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {} + user = ( + (member.get("user") if isinstance(member, dict) else None) + or payload.get("user") + or {} + ) channel_id = str(payload.get("channel_id") or "") guild_id = payload.get("guild_id") # real Discord interaction wire field source = SessionSource( platform=Platform.RELAY, chat_id=channel_id, chat_type="channel" if guild_id else "dm", - user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None, - user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None, - scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot + user_id=str(user.get("id")) + if isinstance(user, dict) and user.get("id") + else None, + user_name=str(user.get("username")) + if isinstance(user, dict) and user.get("username") + else None, + scope_id=str(guild_id) + if guild_id + else None, # Discord guild → generic scope slot message_id=str(payload.get("id")) if payload.get("id") else None, ) event = MessageEvent(text=text, message_type=message_type, source=source) @@ -531,7 +560,9 @@ class RelayAdapter(BasePlatformAdapter): prompt_id, option_id = decoded msg = payload.get("message") or {} prompt_message_id = ( - str(msg.get("id")) if isinstance(msg, dict) and msg.get("id") else None + str(msg.get("id")) + if isinstance(msg, dict) and msg.get("id") + else None ) event.prompt_response = { "prompt_id": prompt_id, @@ -584,7 +615,9 @@ class RelayAdapter(BasePlatformAdapter): sub_name = str(opt.get("name") or "").strip() if sub_name: parts.append(sub_name) - parts.extend(RelayAdapter._render_interaction_options(opt.get("options"))) + parts.extend( + RelayAdapter._render_interaction_options(opt.get("options")) + ) else: value = opt.get("value") if value is not None and str(value).strip(): @@ -708,12 +741,21 @@ class RelayAdapter(BasePlatformAdapter): ) if self._transport is None: return SendResult(success=False, error="no transport") + # Native _resolve_thread_ts parity: a Slack DM reply must post flat at + # the DM root, not threaded under the triggering message. Drop the + # synthetic self-anchor from BOTH the top-level reply_to and the mirrored + # metadata.reply_to_message_id so the connector can't thread on either. + effective_reply_to = self._resolve_reply_to_for_send( + chat_id, reply_to, send_metadata + ) + if effective_reply_to is None and reply_to is not None: + send_metadata.pop("reply_to_message_id", None) result = await self._transport.send_outbound( { "op": "send", "chat_id": chat_id, "content": content, - "reply_to": reply_to, + "reply_to": effective_reply_to, "metadata": self._with_scope(chat_id, send_metadata), }, platform=self._platform_by_chat.get(str(chat_id)), @@ -724,6 +766,57 @@ class RelayAdapter(BasePlatformAdapter): error=result.get("error"), ) + def _resolve_reply_to_for_send( + self, + chat_id: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Optional[str]: + """Suppress the synthetic-DM thread anchor for a Slack DM reply. + + A DM turn's streaming reply is sent with ``reply_to`` = the triggering + message's ts (the stream consumer's ``initial_reply_to_id``, used as the + edit anchor and, on threading platforms, the reply target). The + connector's slackRestSender maps a raw ``reply_to`` to a Slack + ``thread_ts``, so a plain DM reply would be posted THREADED under the + user's message instead of flat at the DM root — and a threaded first + send loses the progressive edit-streaming the user sees in a real + thread (the reported symptom: DM/home replies arrive flat, no + progressive edits). + + Native Slack Hermes already suppresses this synthetic DM thread anchor: + ``SlackAdapter._resolve_thread_ts`` returns ``None`` for a top-level / + DM message when ``reply_in_thread`` is off. The relay lane has no such + disambiguation, so we reproduce it here. run.py already encodes the + real-thread decision in ``metadata["thread_id"]`` (it is set only when + progress threading is active — a real thread, or channel autoThread); + for a DM with no real thread that key is absent. So the rule is: + + Slack DM + no real ``thread_id`` in metadata ⇒ drop ``reply_to``. + + This posts the reply flat at the DM root and lets the consumer edit its + own first-send ts — streaming works exactly as in a thread. It does NOT: + * reintroduce a synthetic DM thread_id (#18859 / the /sethome + landmine) — it removes an anchor, never adds one; + * regress real-thread streaming — a real thread carries a distinct + ``thread_id`` in metadata, so the guard leaves ``reply_to`` alone; + * regress channel autoThread — a channel/group top-level reply carries + ``thread_id`` (the message's own ts) in metadata when threading is + on, so it is left alone; and a non-DM chat is never matched here. + """ + if reply_to is None: + return None + if self._platform_by_chat.get(str(chat_id)) != Platform.SLACK.value: + return reply_to + if self._chat_type_by_chat.get(str(chat_id)) != "dm": + return reply_to + md = metadata or {} + if md.get("thread_id") or md.get("thread_ts"): + # A real thread was resolved by run.py — honour it. + return reply_to + # Synthetic DM self-anchor: post flat at the DM root (native parity). + return None + async def edit_message( self, chat_id: str, @@ -1029,8 +1122,12 @@ class RelayAdapter(BasePlatformAdapter): if result is not None: return result return await super().send_image_file( - chat_id, image_path, caption=caption, reply_to=reply_to, - metadata=metadata, **kwargs, + chat_id, + image_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + **kwargs, ) async def send_voice( @@ -1055,8 +1152,12 @@ class RelayAdapter(BasePlatformAdapter): if result is not None: return result return await super().send_voice( - chat_id, audio_path, caption=caption, reply_to=reply_to, - metadata=metadata, **kwargs, + chat_id, + audio_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + **kwargs, ) async def send_video( @@ -1081,8 +1182,12 @@ class RelayAdapter(BasePlatformAdapter): if result is not None: return result return await super().send_video( - chat_id, video_path, caption=caption, reply_to=reply_to, - metadata=metadata, **kwargs, + chat_id, + video_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + **kwargs, ) async def send_document( @@ -1109,13 +1214,20 @@ class RelayAdapter(BasePlatformAdapter): if result is not None: return result return await super().send_document( - chat_id, file_path, caption=caption, file_name=file_name, - reply_to=reply_to, metadata=metadata, **kwargs, + chat_id, + file_path, + caption=caption, + file_name=file_name, + reply_to=reply_to, + metadata=metadata, + **kwargs, ) # ── Phase 3 interactive: prompt + react ────────────────────────────── - def _mint_prompt(self, kind: str, state: Dict[str, Any], timeout_s: float = 3600.0) -> str: + def _mint_prompt( + self, kind: str, state: Dict[str, Any], timeout_s: float = 3600.0 + ) -> str: """Register a pending prompt and return its 8-hex id. ``state`` carries what the resolver needs when the answer comes back @@ -1135,7 +1247,9 @@ class RelayAdapter(BasePlatformAdapter): # Opportunistic sweep so abandoned prompts can't accumulate: drop # anything already expired (cheap — dict is small by construction). now = time.time() - for stale in [k for k, v in self._pending_prompts.items() if v.get("expires_at", 0) < now]: + for stale in [ + k for k, v in self._pending_prompts.items() if v.get("expires_at", 0) < now + ]: self._pending_prompts.pop(stale, None) return prompt_id @@ -1150,6 +1264,55 @@ class RelayAdapter(BasePlatformAdapter): return None return state + def _strip_synthetic_dm_thread( + self, chat_id: str, metadata: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """Drop the synthetic DM thread anchor from an interactive prompt's metadata. + + A clarify/approval/confirm prompt is emitted mid-turn in reply to the + triggering inbound event, so ``metadata`` carries that event's thread + context — run.py's ``_thread_metadata_for_source`` stamps + ``metadata["thread_id"]`` (and, for Slack, ``metadata["message_id"]`` = + the triggering message ts). For a Slack DM with no REAL thread, that + ``thread_id`` is the message's own synthetic self-anchor (a session-keying + fallback), and forwarding it makes the connector's slackRestSender thread + the prompt card UNDER the user's message instead of posting it flat at the + DM root — the reported bug ("approval block was put in a thread"). + + Native Slack Hermes already suppresses this synthetic DM thread anchor + (``SlackAdapter._resolve_thread_ts`` returns ``None`` for a top-level / DM + message). We reproduce it here with the same discipline used on the + streaming path (``_resolve_reply_to_for_send``): + + Slack DM + thread_id is the synthetic self-anchor ⇒ strip thread_id. + + A REAL thread (``thread_id`` distinct from the triggering message ts) is + left untouched so a prompt raised inside a thread stays in that thread; + non-DM / non-Slack chats are never matched. Only the threading keys are + removed — tenant scope (``scope_id`` / ``slack_team_id``) and everything + else survive so egress routing is unaffected. + """ + if not metadata: + return metadata + if self._platform_by_chat.get(str(chat_id)) != Platform.SLACK.value: + return metadata + if self._chat_type_by_chat.get(str(chat_id)) != "dm": + return metadata + thread_id = metadata.get("thread_id") + if not thread_id: + return metadata + # A real thread carries a thread_id distinct from the triggering message + # ts (run.py stamps that ts as metadata["message_id"] on Slack). Only the + # synthetic self-anchor (thread_id == that ts, or no distinguishing anchor + # present) is stripped; a genuine thread is honoured. + anchor = metadata.get("message_id") + if anchor is not None and str(thread_id) != str(anchor): + return metadata + cleaned = dict(metadata) + cleaned.pop("thread_id", None) + cleaned.pop("thread_ts", None) + return cleaned + async def _send_prompt( self, chat_id: str, @@ -1171,6 +1334,16 @@ class RelayAdapter(BasePlatformAdapter): """ if self._transport is None or not self.descriptor.supports_op("prompt"): return None + # An interactive prompt (approval / clarify / slash-confirm) is emitted + # mid-turn in reply to the triggering inbound event, so `metadata` carries + # that event's thread context (run.py _thread_metadata_for_source stamps + # metadata.thread_id — for a Slack DM the triggering message's own ts, + # used only as a session-keying fallback). Forwarding it makes the + # connector thread the prompt card UNDER the triggering message instead + # of posting it flat at the DM root (the reported bug). Native Slack + # Hermes suppresses this synthetic DM thread anchor; drop it here for the + # same Slack-DM-with-no-real-thread case, matching _resolve_reply_to_for_send. + prompt_metadata = self._strip_synthetic_dm_thread(chat_id, metadata) action: Dict[str, Any] = { "op": "prompt", "chat_id": chat_id, @@ -1178,8 +1351,10 @@ class RelayAdapter(BasePlatformAdapter): "prompt_kind": prompt_kind, "prompt_id": prompt_id, "options": options, - "reply_to": reply_to, - "metadata": self._with_scope(chat_id, metadata), + "reply_to": self._resolve_reply_to_for_send( + chat_id, reply_to, prompt_metadata + ), + "metadata": self._with_scope(chat_id, prompt_metadata), } if timeout_s is not None: action["timeout_s"] = int(timeout_s) @@ -1228,7 +1403,11 @@ class RelayAdapter(BasePlatformAdapter): if not smart_denied and allow_session: options.append({"id": "session", "label": "✅ Session", "style": "primary"}) if allow_permanent: - options.append({"id": "always", "label": "✅ Always", "style": "primary"}) + options.append({ + "id": "always", + "label": "✅ Always", + "style": "primary", + }) options.append({"id": "deny", "label": "❌ Deny", "style": "danger"}) cmd_preview = command if len(command) <= 1500 else command[:1500] + "..." @@ -1238,7 +1417,9 @@ class RelayAdapter(BasePlatformAdapter): f"Reason: {description}" ) if smart_denied: - text += "\n\n**Smart DENY:** owner override applies to this one operation only." + text += ( + "\n\n**Smart DENY:** owner override applies to this one operation only." + ) prompt_id = self._mint_prompt( "exec_approval", @@ -1386,7 +1567,11 @@ class RelayAdapter(BasePlatformAdapter): if kind == "exec_approval": from tools.approval import resolve_gateway_approval - choice = option_id if option_id in {"once", "session", "always", "deny"} else "deny" + choice = ( + option_id + if option_id in {"once", "session", "always", "deny"} + else "deny" + ) count = resolve_gateway_approval(session_key, choice) label = { "once": "✅ Approved once", @@ -1399,13 +1584,17 @@ class RelayAdapter(BasePlatformAdapter): # Acknowledge in-channel (the connector's prompt message can't # be edited cross-platform yet — edit support varies; a short # confirmation preserves the audit trail the native edit gives). - await self.send(chat_id, label, metadata=self._prompt_reply_metadata(event)) + await self.send( + chat_id, label, metadata=self._prompt_reply_metadata(event) + ) if count: self.resume_typing_for_chat(chat_id) elif kind == "slash_confirm": from tools import slash_confirm as slash_confirm_mod - choice = option_id if option_id in {"once", "always", "cancel"} else "cancel" + choice = ( + option_id if option_id in {"once", "always", "cancel"} else "cancel" + ) result_text = await slash_confirm_mod.resolve( session_key, str(state.get("confirm_id") or ""), choice ) @@ -1414,13 +1603,20 @@ class RelayAdapter(BasePlatformAdapter): "always": "🔒 Always approve", "cancel": "❌ Cancelled", }.get(choice, "Resolved") - await self.send(chat_id, label, metadata=self._prompt_reply_metadata(event)) + await self.send( + chat_id, label, metadata=self._prompt_reply_metadata(event) + ) if result_text: await self.send( - chat_id, str(result_text), metadata=self._prompt_reply_metadata(event) + chat_id, + str(result_text), + metadata=self._prompt_reply_metadata(event), ) elif kind == "clarify": - from tools.clarify_gateway import mark_awaiting_text, resolve_gateway_clarify + from tools.clarify_gateway import ( + mark_awaiting_text, + resolve_gateway_clarify, + ) clarify_id = str(state.get("clarify_id") or "") if option_id == "other": diff --git a/tests/gateway/relay/test_relay_slack_dm_streaming.py b/tests/gateway/relay/test_relay_slack_dm_streaming.py new file mode 100644 index 0000000000000..008f6c608c0e9 --- /dev/null +++ b/tests/gateway/relay/test_relay_slack_dm_streaming.py @@ -0,0 +1,220 @@ +"""Slack relay: edit-based streaming of the reply must fire in a DM. + +Reported symptom (live): agent responses stream progressively (edit-based) in a +Slack THREAD but arrive FLAT (single message, no progressive edits) in a Slack +DM/home over the relay. + +Root cause: a DM turn's streaming reply is sent with +``reply_to = `` (the stream consumer's +``initial_reply_to_id`` — its edit anchor). The connector's slackRestSender maps +a raw ``reply_to`` to a Slack ``thread_ts``, so a plain DM reply gets threaded +UNDER the user's message instead of posting flat at the DM root, and a threaded +first send loses the progressive edit streaming the user sees in a real thread. +Native ``SlackAdapter._resolve_thread_ts`` already suppresses this synthetic DM +thread anchor; the relay lane had no such disambiguation. + +These are behaviour-contract tests: they assert how the outbound frame relates to +the chat type + thread metadata (the invariant the connector depends on), not a +snapshot. They drive the REAL ``RelayAdapter`` + ``GatewayStreamConsumer`` + +``StubConnector`` end to end. +""" + +from __future__ import annotations + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.relay.adapter import RelayAdapter +from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor +from gateway.session import SessionSource +from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig + +from tests.gateway.relay.stub_connector import StubConnector + + +def _slack_desc(**kw) -> CapabilityDescriptor: + base = dict( + contract_version=CONTRACT_VERSION, + platform="slack", + label="Slack", + max_message_length=4000, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=True, + markdown_dialect="mrkdwn", + len_unit="chars", + emoji="\U0001f4ac", + platform_hint="", + pii_safe=False, + ) + base.update(kw) + return CapabilityDescriptor(**base) + + +def _wire(chat_id: str, chat_type: str, *, user_id="U1", scope_id=None): + """A RelayAdapter fronting Slack, with inbound scope captured for chat_id.""" + stub = StubConnector(_slack_desc()) + adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub) + src = SessionSource( + platform=Platform.SLACK, + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + scope_id=scope_id, + ) + adapter._capture_scope( + MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + ) + return adapter, stub + + +# --------------------------------------------------------------------------- +# The pure disambiguation contract (RelayAdapter.send) +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_slack_dm_reply_drops_synthetic_thread_anchor(): + """A Slack DM reply with no real thread posts FLAT: reply_to is dropped so + the connector cannot thread it under the triggering message.""" + adapter, stub = _wire("D1", "dm") + await adapter.send("D1", "the answer", reply_to="1700.0001") + assert len(stub.sent) == 1 + frame = stub.sent[0] + assert frame["op"] == "send" + # The synthetic self-anchor is suppressed on BOTH surfaces. + assert frame["reply_to"] is None + assert "thread_id" not in (frame["metadata"] or {}) + # And no synthetic thread_id was invented (the #18859 landmine). + assert "thread_ts" not in (frame["metadata"] or {}) + + +@pytest.mark.asyncio +async def test_slack_dm_reply_with_real_thread_keeps_anchor(): + """A DM turn that IS inside a real thread (metadata carries a distinct + thread_id) must keep threading — the guard only drops the synthetic anchor.""" + adapter, stub = _wire("D1", "dm") + await adapter.send( + "D1", "in thread", reply_to="1700.0002", metadata={"thread_id": "1699.9000"} + ) + frame = stub.sent[0] + assert frame["reply_to"] == "1700.0002" + assert frame["metadata"]["thread_id"] == "1699.9000" + + +@pytest.mark.asyncio +async def test_slack_channel_top_level_reply_keeps_autothread_anchor(): + """A channel top-level reply carries thread_id (its own ts) in metadata when + autoThread is on; the DM-only guard must not touch it.""" + adapter, stub = _wire("C1", "channel", scope_id="T1") + await adapter.send( + "C1", "channel reply", reply_to="1700.0003", metadata={"thread_id": "1700.0003"} + ) + frame = stub.sent[0] + assert frame["reply_to"] == "1700.0003" + assert frame["metadata"]["thread_id"] == "1700.0003" + + +@pytest.mark.asyncio +async def test_non_slack_dm_reply_unchanged(): + """The disambiguation is Slack-scoped: a non-Slack relay chat keeps reply_to + (its connector owns its own threading semantics).""" + stub = StubConnector(_slack_desc(platform="discord")) + adapter = RelayAdapter( + PlatformConfig(), _slack_desc(platform="discord"), transport=stub + ) + src = SessionSource( + platform=Platform.DISCORD, chat_id="dc1", chat_type="dm", user_id="U1" + ) + adapter._capture_scope( + MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + ) + await adapter.send("dc1", "hi", reply_to="msg-9") + assert stub.sent[0]["reply_to"] == "msg-9" + + +# --------------------------------------------------------------------------- +# End-to-end: the stream consumer keeps edit-streaming in a DM +# --------------------------------------------------------------------------- +async def _drive_stream(adapter, chat_id, *, metadata, initial_reply_to_id, chat_type): + cfg = StreamConsumerConfig( + edit_interval=0.0, + buffer_threshold=1, + transport="edit", + chat_type=chat_type, + ) + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id=chat_id, + config=cfg, + metadata=metadata, + initial_reply_to_id=initial_reply_to_id, + ) + # Feed progressive deltas, then finalize — mirrors the live delta callback. + for chunk in ("Hel", "lo ", "world", ". Done."): + consumer.on_delta(chunk) + consumer.finish() + await consumer.run() + return consumer + + +@pytest.mark.asyncio +async def test_slack_dm_stream_consumer_edits_own_ts_not_flat(): + """A Slack DM turn (chat_type='dm', no thread, metadata None) still builds a + stream consumer that keeps edit support and emits progressive EDITs of the + reply message — the flat-DM regression contract. + + The connector returns a real message_id for the flat first send, so edit + support must stay on and at least one edit op must be emitted (progressive + streaming), identical to a thread. No synthetic thread is created.""" + adapter, stub = _wire("D1", "dm") + consumer = await _drive_stream( + adapter, + "D1", + metadata=None, # DM: _status_thread_metadata is None in run.py + initial_reply_to_id="1700.0001", # the triggering message ts + chat_type="dm", + ) + + ops = [f["op"] for f in stub.sent] + # First a flat send; edit support stays on so progressive edits CAN flow + # (exact intermediate-frame timing is covered by the stream_consumer unit + # suite — here we assert the DM regression contract: streaming is not + # self-disabled and every edit targets the reply's own ts). + assert ops[0] == "send" + # Edit support survived: message_id set, not the __no_edit__ sentinel. + assert consumer.message_id and consumer.message_id != "__no_edit__" + assert consumer._edit_supported is True + + first_send = stub.sent[0] + # The reply posts FLAT at the DM root — no synthetic thread anchor. + assert first_send["reply_to"] is None + assert "thread_id" not in (first_send["metadata"] or {}) + assert "thread_ts" not in (first_send["metadata"] or {}) + # reply_to_message_id (the mirrored self-anchor) is stripped too. + assert "reply_to_message_id" not in (first_send["metadata"] or {}) + + # Any edits that flowed target the same first-send ts (editing its own + # message), never a synthetic thread. + edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"} + assert edit_ids <= {stub.next_send_result["message_id"]} + + +@pytest.mark.asyncio +async def test_slack_thread_stream_consumer_still_threads_and_streams(): + """Regression guard: a Slack THREAD turn keeps its real thread_id AND streams + (the DM fix must not change the thread path).""" + adapter, stub = _wire("C1", "channel", scope_id="T1") + consumer = await _drive_stream( + adapter, + "C1", + metadata={"thread_id": "1699.9000"}, + initial_reply_to_id="1700.0002", + chat_type="channel", + ) + ops = [f["op"] for f in stub.sent] + assert ops[0] == "send" + assert consumer._edit_supported is True + first_send = stub.sent[0] + # Thread preserved: the real thread_id rides along and reply_to is kept. + assert first_send["metadata"]["thread_id"] == "1699.9000" + assert first_send["reply_to"] == "1700.0002" From 08d67792ab54b415aa63d3d0385c82d6dfebdf6a Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Sun, 26 Jul 2026 17:20:31 +0000 Subject: [PATCH 09/36] fix(relay): post Slack clarify/approval prompts at DM root not in thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt (approval/clarify) is emitted in reply to the triggering inbound event, so its metadata carries that event's synthetic DM thread anchor; forwarded to the connector it threads the Block Kit prompt under the user's message instead of posting flat at the DM root. Main routes all prompts through the single _send_prompt prompt-op choke point, so strip the synthetic DM thread anchor there via _strip_synthetic_dm_thread — preserving real threads (distinct thread_id), tenant scope (scope_id/slack_team_id), and non-DM/non-Slack chats. Preserves main's hp1 prompt-codec; no competing ap:/cl: encoding. --- .../relay/test_relay_slack_prompt_dm_root.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/gateway/relay/test_relay_slack_prompt_dm_root.py diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py new file mode 100644 index 0000000000000..edee3851866e4 --- /dev/null +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -0,0 +1,194 @@ +"""Slack relay: interactive prompts (approval / clarify) must post FLAT at the DM root. + +Reported symptom (live): an approval / clarify Block Kit card raised mid-turn in +a Slack DM was posted THREADED under the triggering message instead of at the DM +root ("the approval block was put in a thread and did not follow the setting"). + +Root cause: a clarify/approval prompt is emitted in reply to the triggering +inbound event, so the metadata handed to ``_send_prompt`` carries that event's +thread context — run.py's ``_thread_metadata_for_source`` stamps +``metadata["thread_id"]`` (for a Slack DM, the triggering message's own ts, used +only as a session-keying fallback), plus ``metadata["message_id"]`` = that same +ts. Forwarding ``thread_id`` makes the connector's slackRestSender thread the +prompt card UNDER the user's message. Native Slack Hermes suppresses this +synthetic DM thread anchor (``SlackAdapter._resolve_thread_ts``); the relay lane +had no such disambiguation. + +These are behaviour-contract tests: they assert how the outbound ``prompt`` frame +relates to the chat type + inherited thread metadata (the invariant the connector +depends on), not a snapshot. They drive the REAL ``RelayAdapter`` + +``StubConnector`` end to end. +""" + +from __future__ import annotations + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.relay.adapter import RelayAdapter +from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor +from gateway.session import SessionSource + +from tests.gateway.relay.stub_connector import StubConnector + +FULL_OPS = ("send", "edit", "typing", "get_chat_info", "send_media", "prompt", "react") + + +def _slack_desc(**kw) -> CapabilityDescriptor: + base = dict( + contract_version=CONTRACT_VERSION, + platform="slack", + label="Slack", + max_message_length=4000, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=True, + markdown_dialect="mrkdwn", + len_unit="chars", + supported_ops=FULL_OPS, + ) + base.update(kw) + return CapabilityDescriptor(**base) + + +def _wire( + chat_id: str, + chat_type: str, + *, + user_id="U1", + scope_id=None, + platform=Platform.SLACK, +): + """A RelayAdapter fronting Slack, with inbound scope + chat_type captured.""" + stub = StubConnector(_slack_desc()) + adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub) + src = SessionSource( + platform=platform, + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + scope_id=scope_id, + ) + adapter._capture_scope( + MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + ) + return adapter, stub + + +def _last_prompt(stub) -> dict: + prompts = [f for f in stub.sent if f["op"] == "prompt"] + assert prompts, "expected a prompt op on the wire" + return prompts[-1] + + +# --------------------------------------------------------------------------- +# DM-root: the synthetic self-anchor is stripped +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_exec_approval_posts_flat_at_dm_root(): + """A Slack DM approval prompt must NOT inherit the triggering message's + synthetic thread_id — it posts flat at the DM root, matching native.""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + # run.py hands the prompt the triggering message's thread context: for a DM + # with no real thread, thread_id == message_id (the synthetic self-anchor). + md = { + "thread_id": "1700000000.000100", + "message_id": "1700000000.000100", + "scope_id": "T1", + } + result = await adapter.send_exec_approval( + "D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md + ) + assert result.success is True + frame = _last_prompt(stub) + meta = frame["metadata"] or {} + # The inherited synthetic thread anchor is dropped so it posts at the DM root. + assert "thread_id" not in meta, ( + "approval prompt must NOT inherit the triggering message thread_id" + ) + assert "thread_ts" not in meta + # reply_to on the outbound action stays unset — a root-level post. + assert frame["reply_to"] is None + # Tenant scope is preserved untouched (egress routing must not break). + assert meta.get("scope_id") == "T1" + # The caller's original metadata dict was not mutated in place. + assert md.get("thread_id") == "1700000000.000100" + + +@pytest.mark.asyncio +async def test_clarify_posts_flat_at_dm_root(): + """A Slack DM clarify prompt (with choices) also posts flat at the DM root.""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + md = { + "thread_id": "1700000000.000200", + "message_id": "1700000000.000200", + "scope_id": "T1", + } + result = await adapter.send_clarify( + "D1", "Which env?", ["prod", "staging"], "cl-1", "sess:1", metadata=md + ) + assert result.success is True + frame = _last_prompt(stub) + meta = frame["metadata"] or {} + assert "thread_id" not in meta + assert "thread_ts" not in meta + assert frame["reply_to"] is None + assert meta.get("scope_id") == "T1" + + +@pytest.mark.asyncio +async def test_slash_confirm_posts_flat_at_dm_root(): + """The DM-root rule covers every prompt surface (single _send_prompt choke).""" + adapter, stub = _wire("D1", "dm") + md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"} + await adapter.send_slash_confirm( + "D1", "Reload MCP", "invalidates cache", "s", "cf-1", metadata=md + ) + frame = _last_prompt(stub) + assert "thread_id" not in (frame["metadata"] or {}) + + +# --------------------------------------------------------------------------- +# Regression guards: a REAL thread and non-DM / non-Slack chats are untouched +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_exec_approval_in_real_thread_keeps_thread_id(): + """A DM prompt raised inside a REAL thread (thread_id distinct from the + triggering message ts) must STAY in that thread — only the synthetic + self-anchor is stripped.""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + md = { + "thread_id": "1699000000.999000", + "message_id": "1700000000.000100", + "scope_id": "T1", + } + await adapter.send_exec_approval("D1", "cmd", "s", metadata=md) + frame = _last_prompt(stub) + assert frame["metadata"]["thread_id"] == "1699000000.999000" + + +@pytest.mark.asyncio +async def test_channel_approval_keeps_thread_id(): + """A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread); + the DM-only guard must not touch a non-DM chat.""" + adapter, stub = _wire("C1", "channel", scope_id="T1") + md = { + "thread_id": "1700000000.000400", + "message_id": "1700000000.000400", + "scope_id": "T1", + } + await adapter.send_exec_approval("C1", "cmd", "s", metadata=md) + frame = _last_prompt(stub) + assert frame["metadata"]["thread_id"] == "1700000000.000400" + + +@pytest.mark.asyncio +async def test_non_slack_dm_approval_keeps_thread_id(): + """The disambiguation is Slack-scoped: a non-Slack relay DM keeps thread_id + (its connector owns its own threading semantics).""" + adapter, stub = _wire("dc1", "dm", platform=Platform.DISCORD) + md = {"thread_id": "9000", "message_id": "9000"} + await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md) + frame = _last_prompt(stub) + assert frame["metadata"]["thread_id"] == "9000" From e286658377ed63f17582be59bdc081811ed63b3d Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Sun, 26 Jul 2026 20:59:52 +0000 Subject: [PATCH 10/36] fix(scripts): encode tool_search_livetest2 output as utf-8 (Windows footgun) check-windows-footguns (blocking CI) flagged a bare Path.write_text() without encoding= at scripts/tool_search_livetest2.py:190, which uses the platform locale encoding on Windows. Pin utf-8. Pre-existing on main; unblocks the required-checks gate for this PR. --- scripts/tool_search_livetest2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tool_search_livetest2.py b/scripts/tool_search_livetest2.py index b81f91f91afd0..522b141685c50 100644 --- a/scripts/tool_search_livetest2.py +++ b/scripts/tool_search_livetest2.py @@ -187,7 +187,7 @@ def run_one(scenario: Dict[str, Any], mode: str, rep: int, out_dir: Path) -> Dic "final_response": base._redact_secrets(final_response)[:500], } out_path = out_dir / f"{scenario['id']}__{'enabled' if enabled else 'disabled'}__rep{rep}.json" - out_path.write_text(json.dumps(rec, indent=1)) + out_path.write_text(json.dumps(rec, indent=1), encoding="utf-8") shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True) return rec From 42e4f70eefdd9651b50b86f3c66542ca28d69ff0 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Sun, 26 Jul 2026 21:32:22 +0000 Subject: [PATCH 11/36] fix(relay): native-parity Slack approval button styles + labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack Block Kit buttons only support style primary (green) / danger (red) / default (white). The relay approval + slash-confirm prompts emitted an invalid style 'success' (Slack silently drops it → white/stroke button) and baked emoji into the labels (non-native). Native Slack Hermes uses plain labels with primary/danger. Map to valid styles (once→primary, deny/cancel→danger, session/always→default) and drop the emoji from labels. The connector already compensates success→primary, but emitting valid values at the source is correct and removes the fragile dependency on that compensation. --- gateway/relay/adapter.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b544748208a87..e50b9771ed50d 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1399,16 +1399,15 @@ class RelayAdapter(BasePlatformAdapter): button→text fallback takes over (same contract as a native adapter's failed button send). """ - options: list = [{"id": "once", "label": "✅ Allow Once", "style": "success"}] + options: list = [{"id": "once", "label": "Allow Once", "style": "primary"}] if not smart_denied and allow_session: - options.append({"id": "session", "label": "✅ Session", "style": "primary"}) + options.append({"id": "session", "label": "Allow Session"}) if allow_permanent: options.append({ "id": "always", - "label": "✅ Always", - "style": "primary", + "label": "Always Allow", }) - options.append({"id": "deny", "label": "❌ Deny", "style": "danger"}) + options.append({"id": "deny", "label": "Deny", "style": "danger"}) cmd_preview = command if len(command) <= 1500 else command[:1500] + "..." text = ( @@ -1455,9 +1454,9 @@ class RelayAdapter(BasePlatformAdapter): gateway's text-intercept flow when the prompt lane is unavailable. """ options = [ - {"id": "once", "label": "✅ Approve Once", "style": "success"}, - {"id": "always", "label": "🔒 Always Approve", "style": "primary"}, - {"id": "cancel", "label": "❌ Cancel", "style": "danger"}, + {"id": "once", "label": "Approve Once", "style": "primary"}, + {"id": "always", "label": "Always Approve"}, + {"id": "cancel", "label": "Cancel", "style": "danger"}, ] text = f"**{title}**\n\n{message}" if title else message prompt_id = self._mint_prompt( From 797c52b5716bcf96c720f37e501df2f74e8cacfd Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 27 Jul 2026 16:40:16 +1000 Subject: [PATCH 12/36] fix(sync): wire org skill pull into the runtime; scrub internal jargon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by manual testing on the branch. 1. ORG SYNC NEVER RAN. The org pull/mirror/gating machinery was fully implemented and unit-tested but had ZERO runtime callers — maybe_pull_org_skills() was referenced only inside a comment, and `hermes sync` had no org path at all. Every code path fell through to personal sync (refs/user//), so org skills never loaded and the feature looked like 'everything syncs to my personal org' even with a valid org token. The unit tests could not catch this: they invoked the functions directly, which is exactly the gap they left open. - cli.py session startup now calls maybe_pull_org_skills() alongside the personal maybe_pull_skills(), fail-quiet. - Auto-pull is gated on real org membership: resolve_org_identity() requires an org role on the token, only issued for multi-member orgs, so a solo account never reaches the network. - `hermes sync pull` refreshes the org mirror too (one pull, both surfaces) and reports what it refreshed. - `hermes sync status` exposes org_available/org_id/org_role/org_skills plus a plain-language summary, so a user can tell whether the org workflow applies instead of it being invisible. 2. INTERNAL JARGON LEAKED TO USERS. Help text and errors exposed internal milestone/spec coordinates: 'Propose a skill ... (M2)', 'Personal skill sync (HSP/1)', 'DEV-PHASE gate closed: your token lacks tool_gateway_admin', 'contract §4.3', and an inert message describing our internal personal-vs-multi-member design split. All rewritten in user language. Feature-local comments/docstrings lost their internal coordinates (§N, M1/M2, design.md, PR numbers) while keeping the explanatory prose. Pre-existing issue references elsewhere in the tree were deliberately left untouched. Tests: 4 new guards, including two that assert the CALL SITES exist so the org pull cannot silently become dead code again (verified failing when the wiring is removed) and one that fails if user-facing help leaks jargon. 344 passed across the sync/skills/prompt suites. Verified against live staging with a real org token: sync status reports org_available=true, org_role=OWNER; sync pull performs the org refresh; the .active_org marker is written with the org id from the token. --- agent/skill_utils.py | 2 +- cli.py | 18 ++- hermes_cli/main.py | 31 ++++- hermes_cli/subcommands/skills.py | 2 +- hermes_cli/subcommands/sync.py | 2 +- tests/agent/test_org_skill_namespace.py | 63 ++++++++++ tools/skills_sync_client.py | 151 +++++++++++++++--------- 7 files changed, 201 insertions(+), 68 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 2f41310f1521c..ea4bb2ea27d23 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -49,7 +49,7 @@ EXCLUDED_SKILL_DIRS = frozenset( # archive workflow preserves a complete old skill package under references/. SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -# ── M2 org-shared skills (hsp-1-contract.md §11) ─────────────────────────── +# ── Org-shared skills (sync contract) ─────────────────────────── # Org mirrors live under ~/.hermes/skills/_org//. Resolution is # TOKEN-GATED via a marker file the sync client writes after verifying the # token (skills_sync_client.pull_org_skills): only the marked org's mirror is diff --git a/cli.py b/cli.py index e556a5ac19dd9..043454af98e04 100644 --- a/cli.py +++ b/cli.py @@ -13355,15 +13355,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: pass - # HSP skill sync — best-effort periodic pull, piggy-backing on the - # curator tick. Inert unless the DEV-PHASE gate is open - # (tool_gateway_admin) and a sync base URL is configured; swallows all - # errors so it never blocks CLI startup. + # Skill sync — best-effort periodic pull, piggy-backing on the + # curator tick. Inert unless the access gate is open and a sync base + # URL is configured; swallows all errors so it never blocks startup. try: from tools.skills_sync_client import maybe_pull_skills maybe_pull_skills() except Exception: pass + + # Org-shared skills — pull the organisation's approved set into the + # read-only mirror. Gated on real org membership: resolve_org_identity + # requires an org role on the token, which is only issued for + # multi-member organisations, so a solo account never reaches the + # network here. Fail-quiet, exactly like the personal pull above. + try: + from tools.skills_sync_client import maybe_pull_org_skills + maybe_pull_org_skills() + except Exception: + pass if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) self._console_print( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 90597526cb7f4..86d0d5eed6e03 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4453,7 +4453,7 @@ def cmd_sync(args): " pull Pull the owner's HEAD, materialize opted-in skills\n" " push Push opted-in skills to the owner's HEAD\n" " now Reconcile now: pull then push\n" - " enable Opt a skill into sync (M1-D opt-in)\n" + " enable Opt a skill into sync\n" " disable Opt a skill out of sync\n" " device [--name N] Show or set this device's sync label", file=sys.stderr, @@ -4502,12 +4502,25 @@ def cmd_sync(args): if sub == "status": status = ssc.sync_status() print(_json.dumps(status, indent=2, ensure_ascii=False)) + if status.get("org_available"): + n = len(status.get("org_skills") or []) + print( + f"\nOrg skills: {n} shared skill(s) mirrored read-only from " + f"your organisation (your role: {status.get('org_role')}). " + f"They load alongside your own, labeled by origin.", + file=sys.stderr, + ) + elif status.get("logged_in"): + print( + "\nOrg skills: not applicable — this account isn't a member " + "of a shared organisation.", + file=sys.stderr, + ) if not status.get("logged_in"): print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr) elif not status.get("dev_gate_ok"): print( - "\nDEV-PHASE gate closed: your token lacks 'tool_gateway_admin'. " - "Sync is inert during the dev rollout.", + "\nSync is not enabled for your account yet.", file=sys.stderr, ) elif not status.get("feature_enabled"): @@ -4532,7 +4545,7 @@ def cmd_sync(args): return 1 if not identity.get("dev_gate_ok"): print( - "sync inert: DEV-PHASE gate closed (token lacks 'tool_gateway_admin').", + "sync unavailable: not enabled for your account yet.", file=sys.stderr, ) return 1 @@ -4547,6 +4560,16 @@ def cmd_sync(args): try: if sub == "pull": result = ssc.pull_skills(identity=identity) + # Refresh the org mirror too when this account belongs to an + # organisation (no-op otherwise), so one pull covers both. + org_result = ssc.maybe_pull_org_skills() + if org_result: + n = len(org_result.get("updated") or []) + print( + f"org: refreshed {n} shared skill(s) from your " + f"organisation.", + file=sys.stderr, + ) elif sub == "push": result = ssc.push_skills(identity=identity, message="hermes sync push") elif sub == "now": diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index b3c23a95a6e0f..291697f5aff05 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -320,7 +320,7 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: # reports that instead of failing opaquely). skills_propose = skills_subparsers.add_parser( "propose", - help="Propose a skill to your org's shared skill set (M2)", + help="Propose a skill to your organisation's shared skill set", description=( "Snapshot the local skill and submit it to the org canonical set. " "An org admin's push merges directly; a member's push becomes a " diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index b6d4d40d20956..f752aa288cb7f 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -26,7 +26,7 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: """Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``.""" sync_parser = subparsers.add_parser( "sync", - help="Personal skill sync (HSP/1)", + help="Personal skill sync across your devices", description="Sync agent-created and user-authored skills across devices.", ) sync_sub = sync_parser.add_subparsers(dest="sync_command") diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index a37a555ee0077..9a4c7783f33fc 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -174,3 +174,66 @@ class TestOrgMirrorReadOnly: d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") monkeypatch.setattr(su, "_skills_dir", lambda: skills) assert su.is_curation_eligible("shared-x", d) is False + + +class TestOrgPullIsWiredIn: + """Guards the integration gap that unit tests structurally cannot catch. + + The org pull functions were fully implemented and unit-tested while having + ZERO runtime callers, so org skills never loaded for anyone. Testing the + functions directly could never surface that. These tests assert the CALL + SITES exist, so the feature can't silently become dead code again. + """ + + def test_session_startup_calls_maybe_pull_org_skills(self): + import pathlib + + cli_src = ( + pathlib.Path(__file__).resolve().parents[2] / "cli.py" + ).read_text(encoding="utf-8") + assert "maybe_pull_org_skills" in cli_src, ( + "cli.py session startup must call maybe_pull_org_skills() — " + "without a call site the org mirror is never populated and org " + "skills never load (the function being importable is not enough)." + ) + # It must sit alongside the personal pull, not replace it. + assert "maybe_pull_skills" in cli_src + + def test_sync_pull_command_refreshes_org_mirror(self): + import pathlib + + main_src = ( + pathlib.Path(__file__).resolve().parents[2] + / "hermes_cli" + / "main.py" + ).read_text(encoding="utf-8") + assert "maybe_pull_org_skills" in main_src, ( + "`hermes sync pull` must also refresh the org mirror." + ) + + def test_sync_status_exposes_org_state(self): + from tools import skills_sync_client as ssc + + status = ssc.sync_status() + # These keys must always be present so a user can tell whether the org + # workflow applies to them, rather than it being invisible. + for key in ("org_available", "org_id", "org_role", "org_skills"): + assert key in status, f"sync status must expose {key!r}" + + def test_no_internal_jargon_in_user_facing_strings(self): + """User-visible help/errors must not leak internal design coordinates.""" + import pathlib + import re + + root = pathlib.Path(__file__).resolve().parents[2] + targets = [ + root / "hermes_cli" / "subcommands" / "sync.py", + root / "hermes_cli" / "subcommands" / "skills.py", + ] + banned = re.compile(r"\(M[12]\)|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract") + for path in targets: + for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1): + if "help=" in line or "description=" in line: + assert not banned.search(line), ( + f"{path.name}:{i} leaks internal jargon to users: {line.strip()}" + ) diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 6b36463aa5b64..a864a15133aa6 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -57,16 +57,16 @@ from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# HSP/1 protocol constants (contract §1, §3.1) +# Sync protocol constants HSP_VERSION = "1" DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default -# Object kinds (contract §2) +# Object kinds (sync contract) KIND_BLOB = "blob" KIND_TREE = "tree" KIND_COMMIT = "commit" -# Tree entry modes (contract §2.3) +# Tree entry modes (sync contract) MODE_FILE = "file" MODE_EXEC = "exec" MODE_DIR = "dir" @@ -74,9 +74,9 @@ MODE_DIR = "dir" ARTIFACT_TYPE_SKILL = "skill" # --------------------------------------------------------------------------- -# `sync-manifest` object convention (design.md §2.8). +# `sync-manifest` object convention (design notes). # -# Per-skill sync opt-in ("this skill syncs / this one does not" — the M1-D +# Per-skill sync opt-in ("this skill syncs / this one does not" # opt-in state) is CONTENT inside the HSP object model, NOT a device-local flag # or a mutable preference table. An owner's synced set is a small committed blob # named ``sync-manifest`` at the ROOT of the tree referenced by @@ -156,7 +156,7 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: # --------------------------------------------------------------------------- -# Content addressing (contract §2.1 / OI-5) +# Content addressing # # HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT # namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846), @@ -165,12 +165,12 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: # --------------------------------------------------------------------------- def hsp_address(data: bytes) -> str: - """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (contract §2.1).""" + """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (sync contract).""" return "sha256:" + hashlib.sha256(data).hexdigest() def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: - """Canonical JSON serialization for tree/commit hashing (contract §2.5). + """Canonical JSON serialization for tree/commit hashing (sync contract). UTF-8, keys sorted lexicographically, no insignificant whitespace (``separators=(",", ":")``), no trailing newline. Arrays must already be @@ -187,7 +187,7 @@ def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: # --------------------------------------------------------------------------- -# Identity & DEV-PHASE gate +# Identity & access gate # # We reuse resolve_nous_runtime_credentials() for the bearer (it honors the # cross-process file lock + portal host allowlist and refreshes as needed -- @@ -266,7 +266,7 @@ def resolve_identity() -> Dict[str, Any]: def dev_gate_open() -> bool: - """Whether the DEV-PHASE gate permits sync. Never raises.""" + """Whether the access gate permits sync. Never raises.""" try: return bool(resolve_identity().get("dev_gate_ok")) except SyncInertError: @@ -279,7 +279,7 @@ def dev_gate_open() -> bool: # --------------------------------------------------------------------------- # Sync-plane endpoint resolution # -# The HSP routes are mounted under /v1/sync/ (contract §1). The base URL is +# The HSP routes are mounted under /v1/sync/ (sync contract). The base URL is # configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env); # it is NOT the inference base_url. When unset, sync is inert -- there is no # server to talk to yet (the server is being built in parallel). @@ -320,7 +320,7 @@ def resolve_sync_base_url() -> Optional[str]: # # HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL) # HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off) -# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (M1-D policy; default false +# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (personal sync policy; default false # = opt-in. Set true to make # every eligible skill sync # without per-skill enable — @@ -378,7 +378,7 @@ def sync_feature_enabled() -> bool: def sync_default_opt_in() -> bool: - """The M1-D default opt-in policy (env-first). + """The personal sync default opt-in policy (env-first). ``HERMES_SYNC_DEFAULT_OPT_IN`` -> ``sync.default_opt_in`` -> False. @@ -386,7 +386,7 @@ def sync_default_opt_in() -> bool: ``hermes sync enable`` (or a plane manifest that opted it in). True: opt-OUT — every sync-eligible skill is treated as opted in unless explicitly disabled, which is the "your skills follow you with no setup" default a - Hermes Cloud deployment wants. Per design.md §3.0 M1-D this default is + Hermes Cloud deployment wants. Per the design notes, this default is provisional and expected to flip; exposing it as env config lets the operator choose per deployment without a protocol change. """ @@ -394,7 +394,7 @@ def sync_default_opt_in() -> bool: # --------------------------------------------------------------------------- -# Local skill eligibility + the M1-D opt-in "sync" flag +# Local skill eligibility + the personal sync opt-in "sync" flag # # Only agent-created + user-authored skills under ~/.hermes/skills/ sync. # Bundled (.bundled_manifest) and hub-installed skills are excluded. Sync is @@ -413,7 +413,7 @@ def is_sync_eligible(skill_name: str) -> bool: Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT hub-installed, NOT an external-dir skill, and NOT under the org mirror (``_org/`` — enterprise-managed content pulls from the org HEAD and must - never ride a personal push; contract §11.11 / design.md §7.1). Mirrors the + never ride a personal push; the sync contract / the design notes). Mirrors the exclusion logic used by the curator (tools/skill_usage.py). """ try: @@ -508,8 +508,8 @@ def _all_local_skill_names() -> List[str]: # --------------------------------------------------------------------------- # Object building -- turn a skill directory into HSP blob/tree/commit objects # -# A skill dir becomes one tree (contract §2.3). Each file is a blob; each -# subdir a nested tree. The profile-root tree (contract §2.3: "a tree whose +# A skill dir becomes one tree (sync contract). Each file is a blob; each +# subdir a nested tree. The profile-root tree (the sync contract: "a tree whose # entries are category trees") is built from the set of synced skill trees. # --------------------------------------------------------------------------- @@ -565,7 +565,7 @@ def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> if len(data) > max_object_bytes: raise ValueError( f"file {child} is {len(data)} bytes > max_object_bytes " - f"{max_object_bytes} (contract §4.3)" + f"{max_object_bytes}" ) blob_hash = objects.add(KIND_BLOB, data) entries.append( @@ -577,7 +577,7 @@ def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> } ) # else: skip special files - # Entries sorted by name (byte order) for canonicalization (contract §2.3). + # Entries sorted by name (byte order) for canonicalization (sync contract). entries.sort(key=lambda e: e["name"]) tree_obj = {"type": KIND_TREE, "entries": entries} return objects.add(KIND_TREE, canonical_json_bytes(tree_obj)) @@ -593,7 +593,7 @@ def build_commit( objects: ObjectSet, ts: Optional[str] = None, ) -> str: - """Build a commit object (contract §2.4) and return its address. + """Build a commit object (sync contract) and return its address. ``parents``: 0 for first commit, 1 for a normal edit, 2 for a merge commit (order significant: parents[0] = base fast-forwarded from, parents[1] = @@ -632,7 +632,7 @@ def _default_device_label() -> str: def stable_device_id() -> str: """Return a stable per-device label for commit ``author.device`` (contract - §2.4 -- advisory, never an auth input). Persisted under + -- advisory, never an auth input). Persisted under ~/.hermes/skills/.sync_device_id. New devices are seeded with a HUMAN-FRIENDLY default (short hostname + a @@ -688,7 +688,7 @@ def set_device_name(name: str) -> str: # --------------------------------------------------------------------------- # HSP/1 wire client # -# Thin requests-based client for the endpoints in contract §3-§4. Uploads all +# Thin requests-based client for the endpoints in the sync contract- Uploads all # new objects (batch), then CAS-es the ref. A 409 returns the actual head for # the caller's three-way merge. Auth is the Nous bearer resolved above. # --------------------------------------------------------------------------- @@ -711,7 +711,7 @@ class HSPConflict(RuntimeError): class HSPClient: - """HSP/1 client bound to a base URL + bearer (contract §1, routes under + """Sync client bound to a base URL + bearer (routes under ``/v1/sync/``).""" def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0): @@ -729,14 +729,14 @@ class HSPClient: # -- capability & read ------------------------------------------------- def capabilities(self) -> Dict[str, Any]: - """GET /v1/sync/capabilities (contract §3.1). No auth required.""" + """GET /v1/sync/capabilities (sync contract). No auth required.""" r = self._session.get(self._url("capabilities"), timeout=self.timeout) if r.status_code != 200: raise HSPError(f"capabilities failed: {r.status_code}", status=r.status_code) return r.json() def get_refs(self, prefix: str) -> List[Dict[str, str]]: - """GET /v1/sync/refs?prefix=... (contract §3.2).""" + """GET /v1/sync/refs?prefix=... (sync contract).""" r = self._session.get( self._url("refs"), params={"prefix": prefix}, timeout=self.timeout ) @@ -745,7 +745,7 @@ class HSPClient: return (r.json() or {}).get("refs", []) def get_object(self, obj_hash: str) -> Tuple[str, bytes]: - """GET /v1/sync/objects/:hash (contract §3.3). Returns (kind, bytes). + """GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes). Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response (application/octet-stream) is returned as ``blob``. @@ -782,10 +782,10 @@ class HSPClient: *, org_scope: bool = False, ) -> Dict[str, Any]: - """POST /v1/sync/objects (contract §4.2). Batch multi-object upload. + """POST /v1/sync/objects (sync contract). Batch multi-object upload. Contract §1 requires raw object bytes on the wire (NOT base64-in-JSON), - and §4.2 specifies "a length-prefixed or multipart stream of + and specifies "a length-prefixed or multipart stream of {hash, type, bytes}". We use multipart/form-data: one part per object, the part's field name = the claimed ``sha256:`` hash, its ``filename`` carries the object ``type`` (blob|tree|commit), and the @@ -822,7 +822,7 @@ class HSPClient: return r.json() if r.content else {} def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]: - """POST /v1/sync/refs/:name -- atomic compare-and-swap (contract §4.4). + """POST /v1/sync/refs/:name -- atomic compare-and-swap (sync contract). Raises :class:`HSPConflict` (carrying the actual head) on 409. @@ -856,13 +856,13 @@ class HSPClient: # # Records the last commit HEAD we pushed/pulled and, per synced skill, the tree # hash of the on-disk content at that point. Distinct from the bundled manifest -# (skills_sync.py, truncated local content_hash namespace) AND from the §2.8 +# (skills_sync.py, truncated local content_hash namespace) AND from the # `sync-manifest` OBJECT in the sync plane (the per-skill opt-in content). This # is purely local reconciliation bookkeeping. Lives at # ~/.hermes/skills/.sync_state as JSON. # # NOTE: renamed from `.sync_manifest` -> `.sync_state` to remove the collision -# with the §2.8 plane `sync-manifest`. `read_sync_state` migrates an existing +# with the plane `sync-manifest`. `read_sync_state` migrates an existing # `.sync_manifest` on first read so no local head record is lost. # --------------------------------------------------------------------------- @@ -974,9 +974,9 @@ def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None: # Profile snapshot -- build the objects + per-skill tree map for a push # # The profile root is a tree whose entries mirror each synced skill's relative -# path under ~/.hermes/skills/ (contract §2.3: "the profile root is a tree +# path under ~/.hermes/skills/ (the sync contract: "the profile root is a tree # whose entries are category trees"). Only opted-in, eligible skills are -# included (M1-D opt-in + eligibility). +# included (personal sync opt-in + eligibility). # --------------------------------------------------------------------------- def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]: @@ -1039,7 +1039,7 @@ def snapshot_profile( node = node.setdefault(part, {}) node[parts[-1]] = {"__tree__": tree_hash} - # §2.8 sync-manifest: record the opt-in state (the pushed set = enabled). + # sync-manifest: record the opt-in state (the pushed set = enabled). # Only skills that actually made it into the tree are recorded, keyed by the # skill NAME (matching gateway-gateway's manifest shape + the read walk that # enumerates skill subtrees by name). @@ -1087,7 +1087,7 @@ def _build_root_tree( # --------------------------------------------------------------------------- -# Ref naming (contract §2.6) +# Ref naming (sync contract) # --------------------------------------------------------------------------- def user_head_ref(owner: str) -> str: @@ -1158,7 +1158,7 @@ def read_manifest_of_root( def _check_version(caps: Dict[str, Any]) -> None: - """Reject an incompatible server major version (contract §1).""" + """Reject an incompatible server major version (sync contract).""" ver = str(caps.get("hsp_version") or "") major = ver.split(".", 1)[0] if major != HSP_VERSION: @@ -1176,7 +1176,7 @@ def push_skills( identity: Optional[Dict[str, Any]] = None, message: str = "hermes skill sync", ) -> Dict[str, Any]: - """Push opted-in skills to the owner's HEAD (contract §4). + """Push opted-in skills to the owner's HEAD (sync contract). Uploads all new objects, then CAS-es ``refs/user//HEAD``. On a 409, fetches the actual head, three-way merges, and retries once (§4.4 / M1-C). @@ -1234,7 +1234,7 @@ def push_skills( # --------------------------------------------------------------------------- -# Conflict resolution / three-way merge (contract §4.4, M1-C) +# Conflict resolution / three-way merge # # On a 409 the server hands back the actual head. We fetch it, three-way merge # per skill against the base we forked from, reusing the origin/user/incoming @@ -1297,7 +1297,7 @@ def _resolve_push_conflict( # decision == "none": skill deleted on the winning side -> drop if overlaps: - # TRUE OVERLAP -> write a conflict head and surface it (M1-C). + # TRUE OVERLAP -> write a conflict head and surface it (personal sync). n = _next_conflict_index(client, owner) conflict_ref = user_conflict_ref(owner, n) try: @@ -1447,7 +1447,7 @@ def pull_skills( root_tree = _root_tree_of_commit(client, head) remote_trees = _skill_trees_of_root(client, root_tree) - # §2.8: reconcile local opt-in intent FROM the plane manifest, so a skill the + # : reconcile local opt-in intent FROM the plane manifest, so a skill the # user opted in on another device becomes opted in here too (opt-in is # cross-device content, not a device-local flag). We only ADOPT enables from # the manifest for skills present in the remote tree; we never silently @@ -1506,7 +1506,7 @@ def _opted_in_rel_paths() -> List[str]: # # maybe_pull_skills / maybe_push_skills clone the shape of the curator's # maybe_run_curator (agent/curator.py:1998): best-effort, never raise, return -# a result dict or None. The DEV-PHASE gate is checked first -- sync is inert +# a result dict or None. The access gate is checked first -- sync is inert # (no push, no pull, no-op) unless tool_gateway_admin === true on the token. # --------------------------------------------------------------------------- @@ -1516,7 +1516,7 @@ def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[st try: identity = resolve_identity() if not identity.get("dev_gate_ok"): - return None # DEV-PHASE gate: inert without tool_gateway_admin + return None # access gate: inert without tool_gateway_admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1536,7 +1536,7 @@ def maybe_pull_skills() -> Optional[Dict[str, Any]]: try: identity = resolve_identity() if not identity.get("dev_gate_ok"): - return None # DEV-PHASE gate: inert without tool_gateway_admin + return None # access gate: inert without tool_gateway_admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1558,6 +1558,13 @@ def sync_status() -> Dict[str, Any]: "opted_in_skills": [], "local_head": None, "owner": None, + # Org-shared skills. `org_available` is False for an account that + # isn't in a shared organisation — the org workflow does not apply, + # which is different from it being broken or misconfigured. + "org_available": False, + "org_id": None, + "org_role": None, + "org_skills": [], } try: identity = resolve_identity() @@ -1573,24 +1580,55 @@ def sync_status() -> Dict[str, Any]: status["local_head"] = read_sync_state().get("head") except Exception: pass + try: + org_identity = resolve_org_identity() + status["org_available"] = True + status["org_id"] = org_identity.get("org_id") + status["org_role"] = org_identity.get("org_role") + status["org_skills"] = list_org_skill_names() + except SyncInertError: + pass + except Exception as e: + logger.debug("skills_sync_client: sync_status org lookup failed: %s", e) return status +def list_org_skill_names() -> List[str]: + """Skill names present in the local org mirror (empty when none pulled).""" + names: List[str] = [] + try: + from agent.skill_utils import read_active_org_id + + org_id = read_active_org_id(_skills_dir()) + if not org_id: + return names + root = _org_dir() / org_id + if not root.is_dir(): + return names + for skill_md in root.rglob("SKILL.md"): + rel = skill_md.parent.relative_to(root) + if rel.parts: + names.append(str(rel).replace("\\", "/")) + except Exception as e: + logger.debug("skills_sync_client: org skill listing failed: %s", e) + return sorted(names) + + # --------------------------------------------------------------------------- -# M2 org-shared skills (hsp-1-contract.md §11) — org pull + propose. +# Org-shared skills (sync contract) — org pull + propose. # # Org skills live under a DISTINCT local namespace, ~/.hermes/skills/_org/ -# (design.md §7.1: enterprise-managed skills are read-only to the runtime; a +# (the design notes: enterprise-managed skills are read-only to the runtime; a # local edit is a personal fork of record until proposed). The org canonical # set is `refs/org//HEAD` — the SAME object model as personal sync. # -# PERSONAL-ORG GATE (contract §11.1 REFINED, Ben 2026-07-23): a personal org +# PERSONAL-ORG GATE (the sync contract REFINED, Ben 2026-07-23): a personal org # has NO org workflow. The discriminator travels in the token: NAS stamps the # `org_role` claim ONLY for multi-member orgs. No claim ⇒ every org helper # here is inert (org_sync_available() False; pull/propose raise SyncInertError) -# and the personal M1 experience is untouched. +# and the personal personal sync experience is untouched. # -# TRAJECTORY (Ben): `hermes skills propose` is the M2 MVP surface; proposal is +# TRAJECTORY (Ben): `hermes skills propose` is the org sharing MVP surface; proposal is # intended to become largely automated later (curator/background hooks driving # the same propose_skill() path). Keep this callable non-interactive. # --------------------------------------------------------------------------- @@ -1611,11 +1649,10 @@ def resolve_org_identity() -> Dict[str, Any]: org_id = claims.get("org_id") org_role = claims.get("org_role") if not org_id: - raise SyncInertError("token carries no org_id") + raise SyncInertError("no organisation associated with this account") if not isinstance(org_role, str) or not org_role: raise SyncInertError( - "no org_role claim (personal org keeps the simple personal sync; " - "org workflow is multi-member-org only)" + "this account isn't a member of a shared organisation" ) identity["org_id"] = str(org_id) identity["org_role"] = org_role @@ -1636,7 +1673,7 @@ def org_head_ref(org_id: str) -> str: def _org_dir() -> Path: - """Local mirror root for org skills (read-only by convention §7.1).""" + """Local mirror root for org skills (read-only by convention ).""" return _skills_dir() / ORG_DIR_NAME @@ -1655,7 +1692,7 @@ def pull_org_skills( """ identity = identity or resolve_org_identity() if "org_id" not in identity: - raise SyncInertError("identity lacks org context; use resolve_org_identity()") + raise SyncInertError("no organisation context available") org_id = identity["org_id"] base_url = resolve_sync_base_url() if not base_url: @@ -1665,7 +1702,7 @@ def pull_org_skills( caps = client.capabilities() _check_version(caps) if "org" not in (caps.get("features") or []): - raise SyncInertError("server does not advertise the 'org' feature") + raise SyncInertError("this server does not support org-shared skills") refs = client.get_refs(f"refs/org/{org_id}/") head = next( @@ -1704,7 +1741,7 @@ def pull_org_skills( ) # Provenance sidecar for the load-time header (skill_view): the HEAD # commit's author is TOKEN-VERIFIED at push time by the plane - # (author_mismatch guard, gateway-gateway #166) — trustworthy to display. + # (author_mismatch guard, the sync plane) — trustworthy to display. _write_org_provenance( org_id, { @@ -1777,7 +1814,7 @@ def propose_skill( caps = client.capabilities() _check_version(caps) if "org" not in (caps.get("features") or []): - raise SyncInertError("server does not advertise the 'org' feature") + raise SyncInertError("this server does not support org-shared skills") max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES) # Locate the local skill directory (personal namespace, NOT _org/). From d60d981281719ebb9ccbe0fd3b8595a4f7f3886a Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 27 Jul 2026 16:44:27 +1000 Subject: [PATCH 13/36] fix(sync): don't require a base URL when an explicit client is supplied pull_org_skills/propose_skill resolved and demanded HERMES_SYNC_BASE_URL before using the caller-provided `client`, which already carries its own base URL. Only resolve/require it on the path that actually constructs a client. Caught by scripts/run_tests.sh, which blanks env vars to match CI. Plain `pytest` masked it: my shell had HERMES_SYNC_BASE_URL exported from live testing, so the redundant check silently passed. Reproduced deliberately with `env -u HERMES_SYNC_BASE_URL pytest` before fixing. 371 passed / 0 failed across the sync, skills, prompt, and skill-utils suites via the canonical runner. --- tools/skills_sync_client.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index a864a15133aa6..daa582f8c28d5 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -1694,10 +1694,11 @@ def pull_org_skills( if "org_id" not in identity: raise SyncInertError("no organisation context available") org_id = identity["org_id"] - base_url = resolve_sync_base_url() - if not base_url: - raise SyncInertError("no sync base URL configured") - client = client or HSPClient(base_url, identity["api_key"]) + if client is None: + base_url = resolve_sync_base_url() + if not base_url: + raise SyncInertError("no sync base URL configured") + client = HSPClient(base_url, identity["api_key"]) caps = client.capabilities() _check_version(caps) @@ -1806,10 +1807,11 @@ def propose_skill( """ identity = identity or resolve_org_identity() org_id = identity["org_id"] - base_url = resolve_sync_base_url() - if not base_url: - raise SyncInertError("no sync base URL configured") - client = client or HSPClient(base_url, identity["api_key"]) + if client is None: + base_url = resolve_sync_base_url() + if not base_url: + raise SyncInertError("no sync base URL configured") + client = HSPClient(base_url, identity["api_key"]) caps = client.capabilities() _check_version(caps) From 95103db64552f05da214eff7d97191d8530534f4 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 13:50:13 +0000 Subject: [PATCH 14/36] =?UTF-8?q?fix(relay):=20prompts=20trust=20the=20run?= =?UTF-8?q?.py=20thread=20stamp=20=E2=80=94=20no=20self-anchor=20re-deriva?= =?UTF-8?q?tion=20(QA-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threading mode (flat vs thread-per-message) is decided once, in run.py's _resolve_progress_thread_id (reply_in_thread knob), and encoded in the metadata stamp: flat => no thread_id, threaded => thread_id for the turn (first turn: == message_id, the synthetic root IS the thread). _strip_synthetic_dm_thread re-derived the mode with an unconditional thread_id == message_id strip, exiling approval/clarify cards (and their resolved-state swaps) to the DM root while progress bubbles honoured the thread (2026-07-27 mixed-placement report). Trust the stamp instead; flat mode is unaffected because flat metadata never carries an anchor. --- gateway/relay/adapter.py | 28 +++-- .../relay/test_relay_slack_prompt_dm_root.py | 104 ++++++++++-------- 2 files changed, 77 insertions(+), 55 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index e50b9771ed50d..57ad659d6d540 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1301,17 +1301,23 @@ class RelayAdapter(BasePlatformAdapter): thread_id = metadata.get("thread_id") if not thread_id: return metadata - # A real thread carries a thread_id distinct from the triggering message - # ts (run.py stamps that ts as metadata["message_id"] on Slack). Only the - # synthetic self-anchor (thread_id == that ts, or no distinguishing anchor - # present) is stripped; a genuine thread is honoured. - anchor = metadata.get("message_id") - if anchor is not None and str(thread_id) != str(anchor): - return metadata - cleaned = dict(metadata) - cleaned.pop("thread_id", None) - cleaned.pop("thread_ts", None) - return cleaned + # Trust the run.py stamp (QA-5). The threading MODE is decided in ONE + # place — run.py's _resolve_progress_thread_id, which reads + # platforms.slack.extra.reply_in_thread: + # * flat mode (reply_in_thread=false): the synthetic self-anchor is + # suppressed THERE, so prompt metadata arrives with NO thread_id and + # this helper is a no-op — the card posts flat at the DM root; + # * thread-per-message mode (default): metadata.thread_id is stamped + # for the whole turn, and on the FIRST turn it legitimately equals + # the triggering message's ts (the synthetic root IS the thread). + # The previous unconditional thread_id == message_id strip re-derived + # the mode here and got it wrong for thread-per-message: the approval + # card (and its resolved-state swap) was exiled to the DM root while + # progress bubbles honoured the thread (2026-07-27 mixed-placement + # screenshot). Mirror native SlackAdapter._resolve_thread_ts, which + # only performs the self-anchor strip when reply_in_thread=false — a + # state this lane never sees with an anchor present, per the above. + return metadata async def _send_prompt( self, diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index edee3851866e4..f08ba30dec810 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -1,21 +1,24 @@ -"""Slack relay: interactive prompts (approval / clarify) must post FLAT at the DM root. +"""Slack relay: interactive prompts follow the turn's thread stamp (QA-5). -Reported symptom (live): an approval / clarify Block Kit card raised mid-turn in -a Slack DM was posted THREADED under the triggering message instead of at the DM -root ("the approval block was put in a thread and did not follow the setting"). +The threading MODE (flat DM vs thread-per-message) is decided in exactly ONE +place: run.py's ``_resolve_progress_thread_id``, which reads +``platforms.slack.extra.reply_in_thread`` and encodes the verdict into the +outbound ``metadata`` stamp: -Root cause: a clarify/approval prompt is emitted in reply to the triggering -inbound event, so the metadata handed to ``_send_prompt`` carries that event's -thread context — run.py's ``_thread_metadata_for_source`` stamps -``metadata["thread_id"]`` (for a Slack DM, the triggering message's own ts, used -only as a session-keying fallback), plus ``metadata["message_id"]`` = that same -ts. Forwarding ``thread_id`` makes the connector's slackRestSender thread the -prompt card UNDER the user's message. Native Slack Hermes suppresses this -synthetic DM thread anchor (``SlackAdapter._resolve_thread_ts``); the relay lane -had no such disambiguation. + * flat mode -> the synthetic self-anchor is suppressed in run.py, so prompt + metadata arrives with NO ``thread_id`` and the card posts at the DM root; + * thread-per-message (default) -> ``metadata.thread_id`` is stamped for the + whole turn; on the FIRST turn it legitimately equals the triggering + message's ts (the synthetic root IS the thread). -These are behaviour-contract tests: they assert how the outbound ``prompt`` frame -relates to the chat type + inherited thread metadata (the invariant the connector +The prompt lane must TRUST that stamp, like ``_resolve_reply_to_for_send`` +does. Re-deriving the mode here (the old unconditional +``thread_id == message_id`` strip) exiled the approval card and its +resolved-state swap to the DM root while progress bubbles honoured the thread +(the 2026-07-27 mixed-placement report). + +These are behaviour-contract tests: they assert how the outbound ``prompt`` +frame relates to the inherited thread metadata (the invariant the connector depends on), not a snapshot. They drive the REAL ``RelayAdapter`` + ``StubConnector`` end to end. """ @@ -83,15 +86,39 @@ def _last_prompt(stub) -> dict: # --------------------------------------------------------------------------- -# DM-root: the synthetic self-anchor is stripped +# Flat mode: run.py stamps NO thread_id -> the card posts at the DM root. # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_exec_approval_posts_flat_at_dm_root(): - """A Slack DM approval prompt must NOT inherit the triggering message's - synthetic thread_id — it posts flat at the DM root, matching native.""" +async def test_exec_approval_flat_mode_posts_at_dm_root(): + """Flat-DM turn (reply_in_thread=false): run.py suppressed the synthetic + anchor upstream, so prompt metadata has no thread_id and none appears on + the wire — the card posts at the DM root.""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + md = {"message_id": "1700000000.000100", "scope_id": "T1"} + result = await adapter.send_exec_approval( + "D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md + ) + assert result.success is True + frame = _last_prompt(stub) + meta = frame["metadata"] or {} + assert "thread_id" not in meta + assert "thread_ts" not in meta + # reply_to on the outbound action stays unset — a root-level post. + assert frame["reply_to"] is None + # Tenant scope is preserved untouched (egress routing must not break). + assert meta.get("scope_id") == "T1" + + +# --------------------------------------------------------------------------- +# Thread-per-message mode: the first-turn self-anchor (thread_id == message_id) +# IS the thread root — the prompt must stay in the thread (QA-5 regression). +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_exec_approval_first_turn_self_anchor_stays_in_thread(): + """Thread-per-message first turn: run.py stamps thread_id = the triggering + message's own ts. The approval card must post INTO that thread — stripping + it exiled the card to the home channel (2026-07-27 report).""" adapter, stub = _wire("D1", "dm", scope_id="T1") - # run.py hands the prompt the triggering message's thread context: for a DM - # with no real thread, thread_id == message_id (the synthetic self-anchor). md = { "thread_id": "1700000000.000100", "message_id": "1700000000.000100", @@ -103,22 +130,14 @@ async def test_exec_approval_posts_flat_at_dm_root(): assert result.success is True frame = _last_prompt(stub) meta = frame["metadata"] or {} - # The inherited synthetic thread anchor is dropped so it posts at the DM root. - assert "thread_id" not in meta, ( - "approval prompt must NOT inherit the triggering message thread_id" + assert meta.get("thread_id") == "1700000000.000100", ( + "first-turn self-anchor is the thread root; the prompt must honour it" ) - assert "thread_ts" not in meta - # reply_to on the outbound action stays unset — a root-level post. - assert frame["reply_to"] is None - # Tenant scope is preserved untouched (egress routing must not break). assert meta.get("scope_id") == "T1" - # The caller's original metadata dict was not mutated in place. - assert md.get("thread_id") == "1700000000.000100" @pytest.mark.asyncio -async def test_clarify_posts_flat_at_dm_root(): - """A Slack DM clarify prompt (with choices) also posts flat at the DM root.""" +async def test_clarify_first_turn_self_anchor_stays_in_thread(): adapter, stub = _wire("D1", "dm", scope_id="T1") md = { "thread_id": "1700000000.000200", @@ -131,22 +150,21 @@ async def test_clarify_posts_flat_at_dm_root(): assert result.success is True frame = _last_prompt(stub) meta = frame["metadata"] or {} - assert "thread_id" not in meta - assert "thread_ts" not in meta - assert frame["reply_to"] is None + assert meta.get("thread_id") == "1700000000.000200" assert meta.get("scope_id") == "T1" @pytest.mark.asyncio -async def test_slash_confirm_posts_flat_at_dm_root(): - """The DM-root rule covers every prompt surface (single _send_prompt choke).""" +async def test_slash_confirm_first_turn_self_anchor_stays_in_thread(): + """The stamp-trusting rule covers every prompt surface (single + _send_prompt choke point).""" adapter, stub = _wire("D1", "dm") md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"} await adapter.send_slash_confirm( "D1", "Reload MCP", "invalidates cache", "s", "cf-1", metadata=md ) frame = _last_prompt(stub) - assert "thread_id" not in (frame["metadata"] or {}) + assert (frame["metadata"] or {}).get("thread_id") == "1700000000.000300" # --------------------------------------------------------------------------- @@ -155,8 +173,7 @@ async def test_slash_confirm_posts_flat_at_dm_root(): @pytest.mark.asyncio async def test_exec_approval_in_real_thread_keeps_thread_id(): """A DM prompt raised inside a REAL thread (thread_id distinct from the - triggering message ts) must STAY in that thread — only the synthetic - self-anchor is stripped.""" + triggering message ts) stays in that thread.""" adapter, stub = _wire("D1", "dm", scope_id="T1") md = { "thread_id": "1699000000.999000", @@ -170,8 +187,7 @@ async def test_exec_approval_in_real_thread_keeps_thread_id(): @pytest.mark.asyncio async def test_channel_approval_keeps_thread_id(): - """A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread); - the DM-only guard must not touch a non-DM chat.""" + """A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread).""" adapter, stub = _wire("C1", "channel", scope_id="T1") md = { "thread_id": "1700000000.000400", @@ -185,8 +201,8 @@ async def test_channel_approval_keeps_thread_id(): @pytest.mark.asyncio async def test_non_slack_dm_approval_keeps_thread_id(): - """The disambiguation is Slack-scoped: a non-Slack relay DM keeps thread_id - (its connector owns its own threading semantics).""" + """A non-Slack relay DM keeps thread_id (its connector owns its own + threading semantics).""" adapter, stub = _wire("dc1", "dm", platform=Platform.DISCORD) md = {"thread_id": "9000", "message_id": "9000"} await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md) From be9de31967f1b11c1d37ad7a8e337c48bbb3745d Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 14:37:10 +0000 Subject: [PATCH 15/36] =?UTF-8?q?fix(relay):=20final=20DM=20reply=20honors?= =?UTF-8?q?=20thread-per-message=20mode=20=E2=80=94=20gate=20the=20reply?= =?UTF-8?q?=5Fto=20strip=20on=20reply=5Fin=5Fthread=20(QA-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_reply_to_for_send dropped the triggering-ts reply_to on every Slack DM with no metadata thread_id. But the final-reply lane (platforms/ base.py) builds metadata from source.thread_id only — None for a top-level DM — so in thread-per-message mode that reply_to is the final reply's ONLY threading signal, and stripping it exiled the final message to the DM root while progress bubbles stayed threaded (sibling of the QA-5 prompt bug). Mirror native _resolve_thread_ts: suppress the synthetic anchor only when platforms.slack.extra.reply_in_thread=false. Flat mode behavior unchanged; real threads and channels unchanged. --- gateway/relay/adapter.py | 20 ++++++- .../relay/test_relay_slack_dm_streaming.py | 53 ++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 57ad659d6d540..7bf687cb49f3e 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -814,7 +814,25 @@ class RelayAdapter(BasePlatformAdapter): if md.get("thread_id") or md.get("thread_ts"): # A real thread was resolved by run.py — honour it. return reply_to - # Synthetic DM self-anchor: post flat at the DM root (native parity). + # Mode gate (native _resolve_thread_ts parity). The final-reply lane + # (gateway/platforms/base.py) builds metadata from source.thread_id + # ONLY — for a top-level DM that is None, so in thread-per-message + # mode the triggering-ts reply_to here is the final reply's ONLY + # threading signal (run.py's synthetic root feeds just the + # progress/status lane). Dropping it unconditionally exiled the final + # message to the DM root while progress stayed threaded (2026-07-27 + # report, sibling of the QA-5 prompt bug). Native SlackAdapter only + # suppresses the anchor when reply_in_thread=false; mirror that. + try: + reply_in_thread = bool( + (self.config.extra or {}).get("reply_in_thread", True) + ) + except Exception: # noqa: BLE001 - config shape is adapter-owned + reply_in_thread = True + if reply_in_thread: + # Thread-per-message: the triggering ts is the thread anchor. + return reply_to + # Flat mode: synthetic DM self-anchor — post flat at the DM root. return None async def edit_message( diff --git a/tests/gateway/relay/test_relay_slack_dm_streaming.py b/tests/gateway/relay/test_relay_slack_dm_streaming.py index 008f6c608c0e9..35b3d5f989cc4 100644 --- a/tests/gateway/relay/test_relay_slack_dm_streaming.py +++ b/tests/gateway/relay/test_relay_slack_dm_streaming.py @@ -73,18 +73,33 @@ def _wire(chat_id: str, chat_type: str, *, user_id="U1", scope_id=None): # The pure disambiguation contract (RelayAdapter.send) # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_slack_dm_reply_drops_synthetic_thread_anchor(): - """A Slack DM reply with no real thread posts FLAT: reply_to is dropped so - the connector cannot thread it under the triggering message.""" +async def test_slack_dm_reply_keeps_anchor_in_thread_per_message_mode(): + """Default mode (reply_in_thread=True, thread-per-message): the triggering + ts reply_to is the final reply's ONLY threading signal (base.py builds + metadata from source.thread_id, which is None for a top-level DM) — it + must be KEPT so the final message lands in the per-message thread with + the progress bubbles (2026-07-27 mixed-placement report).""" adapter, stub = _wire("D1", "dm") await adapter.send("D1", "the answer", reply_to="1700.0001") assert len(stub.sent) == 1 frame = stub.sent[0] assert frame["op"] == "send" - # The synthetic self-anchor is suppressed on BOTH surfaces. + assert frame["reply_to"] == "1700.0001", ( + "thread-per-message: the triggering ts anchors the final reply" + ) + + +@pytest.mark.asyncio +async def test_slack_dm_reply_drops_synthetic_anchor_in_flat_mode(): + """Flat mode (reply_in_thread=False): the synthetic self-anchor is dropped + so the reply posts flat at the DM root (native _resolve_thread_ts parity) + and no synthetic thread is invented (#18859).""" + adapter, stub = _wire("D1", "dm") + adapter.config.extra = {"reply_in_thread": False} + await adapter.send("D1", "the answer", reply_to="1700.0001") + frame = stub.sent[0] assert frame["reply_to"] is None assert "thread_id" not in (frame["metadata"] or {}) - # And no synthetic thread_id was invented (the #18859 landmine). assert "thread_ts" not in (frame["metadata"] or {}) @@ -165,8 +180,13 @@ async def test_slack_dm_stream_consumer_edits_own_ts_not_flat(): The connector returns a real message_id for the flat first send, so edit support must stay on and at least one edit op must be emitted (progressive - streaming), identical to a thread. No synthetic thread is created.""" + streaming), identical to a thread. No synthetic thread is created. + + Runs in EXPLICIT flat mode (reply_in_thread=False) — that is the mode this + contract belongs to; the default thread-per-message path is covered by + test_slack_dm_stream_consumer_threads_in_thread_per_message_mode.""" adapter, stub = _wire("D1", "dm") + adapter.config.extra = {"reply_in_thread": False} consumer = await _drive_stream( adapter, "D1", @@ -218,3 +238,24 @@ async def test_slack_thread_stream_consumer_still_threads_and_streams(): # Thread preserved: the real thread_id rides along and reply_to is kept. assert first_send["metadata"]["thread_id"] == "1699.9000" assert first_send["reply_to"] == "1700.0002" + + +@pytest.mark.asyncio +async def test_slack_dm_stream_consumer_threads_in_thread_per_message_mode(): + """Default mode: the DM stream's first send keeps the triggering-ts anchor + so the streamed final reply lands in the per-message thread; edits still + target the reply's own ts.""" + adapter, stub = _wire("D1", "dm") + consumer = await _drive_stream( + adapter, + "D1", + metadata=None, + initial_reply_to_id="1700.0001", + chat_type="dm", + ) + first_send = stub.sent[0] + assert first_send["op"] == "send" + assert first_send["reply_to"] == "1700.0001" + assert consumer.message_id and consumer.message_id != "__no_edit__" + edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"} + assert edit_ids <= {stub.next_send_result["message_id"]} From b493bf63c7e327bed92a47937edd87855d7f52f7 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 14:04:46 +0000 Subject: [PATCH 16/36] =?UTF-8?q?feat(relay):=20rich=20Slack=20status-line?= =?UTF-8?q?=20parity=20=E2=80=94=20advertise=20supports=5Fstatus=5Ftext,?= =?UTF-8?q?=20carry=20live=20per-tool=20phrase=20on=20typing=20frames=20(Q?= =?UTF-8?q?A-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Slack shows dynamic assistant-status text ('Finding answers…', 'is running pytest…') because SlackAdapter sets supports_status_text=True and renders the set_status_text() phrase in send_typing. The relay lane advertised nothing, so run.py's live-status lane never fed it phrases and the connector fell back to the static default. - supports_status_text: descriptor-gated property (Slack only; other fronted platforms keep textless bubbles) - send_typing: carry the stashed phrase as the typing op's content; omit when unset (empty string is Slack's explicit clear, reserved for stop_typing). Connector already renders content via assistant.threads.setStatus (#154). --- gateway/relay/adapter.py | 40 ++++++++++++++++--- .../relay/test_relay_slack_prompt_dm_root.py | 38 ++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 7bf687cb49f3e..fff21bb6a94b5 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -133,6 +133,23 @@ class RelayAdapter(BasePlatformAdapter): def message_len_fn(self) -> Callable[[str], int]: return _LEN_FNS.get(self.descriptor.len_unit, len) + @property + def supports_status_text(self) -> bool: # type: ignore[override] + """Whether the fronted platform renders a TEXT status line. + + Native parity (QA-1 rich status): Slack's typing surface is the + assistant status line ("Finding answers…" next to the bot name), a + text-rendering indicator. When the relay fronts Slack, advertise it so + run.py's live-status lane feeds per-tool phrases via + ``set_status_text()`` — exactly the wiring the native SlackAdapter + gets (``supports_status_text = True``). Other fronted platforms keep + textless typing bubbles and must NOT receive phrase traffic. + + Property (not class attr) because ONE RelayAdapter class fronts many + platforms; the answer depends on the handshaked descriptor. + """ + return self.descriptor.platform == Platform.SLACK.value + def supports_draft_streaming( self, chat_type: Optional[str] = None, @@ -892,13 +909,26 @@ class RelayAdapter(BasePlatformAdapter): """ if self._transport is None: return + # Rich status parity (QA-1): run.py's live-status lane stashes the + # current per-tool phrase via set_status_text() (base class store). + # Carry it as the typing frame's content so the connector's Slack + # sender renders it on assistant.threads.setStatus — the same phrase + # the native adapter shows ("is running pytest…", "Finding answers…"). + # Absent (None/empty) => omit content; the connector falls back to its + # default "is typing…" heartbeat, preserving pre-phrase behaviour on + # every platform. Never send empty-string content here: on Slack that + # is the explicit CLEAR request reserved for stop_typing. + frame: Dict[str, Any] = { + "op": "typing", + "chat_id": chat_id, + "metadata": self._with_scope(chat_id, metadata), + } + phrase = getattr(self, "_status_text", {}).get(str(chat_id)) + if phrase: + frame["content"] = str(phrase) try: await self._transport.send_outbound( - { - "op": "typing", - "chat_id": chat_id, - "metadata": self._with_scope(chat_id, metadata), - }, + frame, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: # noqa: BLE001 - typing is cosmetic, never breaks a turn diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index f08ba30dec810..5e48e972b8dec 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -208,3 +208,41 @@ async def test_non_slack_dm_approval_keeps_thread_id(): await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md) frame = _last_prompt(stub) assert frame["metadata"]["thread_id"] == "9000" + + +# --------------------------------------------------------------------------- +# QA-1 rich status: the relay advertises Slack's text status line and carries +# the live per-tool phrase on the typing frame (native set_status_text parity). +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_slack_relay_advertises_status_text(): + adapter, _stub = _wire("D1", "dm") + assert adapter.supports_status_text is True + + +@pytest.mark.asyncio +async def test_non_slack_relay_does_not_advertise_status_text(): + stub = StubConnector(_slack_desc(platform="discord")) + adapter = RelayAdapter( + PlatformConfig(), _slack_desc(platform="discord"), transport=stub + ) + assert adapter.supports_status_text is False + + +@pytest.mark.asyncio +async def test_typing_carries_live_status_phrase(): + """set_status_text() -> the next typing frame carries the phrase as + content; clearing it (None) reverts to a content-less heartbeat frame + (never an empty string, which is Slack's explicit clear).""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + adapter.set_status_text("D1", "is running pytest…") + await adapter.send_typing("D1", metadata={"scope_id": "T1"}) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and typing[-1].get("content") == "is running pytest…" + + adapter.set_status_text("D1", None) + await adapter.send_typing("D1", metadata={"scope_id": "T1"}) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert "content" not in typing[-1], ( + "cleared phrase must omit content (empty string means CLEAR on Slack)" + ) From 467534b43ed87e55861d0ef87ebd7822f313bc3d Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 15:03:51 +0000 Subject: [PATCH 17/36] =?UTF-8?q?fix(relay):=20typing/status=20targets=20t?= =?UTF-8?q?he=20per-message=20thread=20=E2=80=94=20synthesize=20the=20anch?= =?UTF-8?q?or=20from=20the=20inbound=20ts=20(QA-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack's thinking-status line (thread replies footer, plain chat:write — no assistant scopes needed) is thread-only: the connector's typing case no-ops without thread_ts. The typing lane's metadata has no anchor for a top-level DM (base.py builds from source.thread_id = None), so every status heartbeat was silently dropped — the trace showed typing frames with meta_keys=['user_id'] only. Cache the triggering message ts per chat on inbound (_capture_scope) and synthesize metadata.thread_id on send_typing/stop_typing in thread-per-message mode, mirroring native send_typing's _resolve_thread_ts(metadata.message_id). Flat mode unchanged (#18859); real-thread metadata wins over the cache; the clear frame targets the same synthesized thread so the status never sticks. --- gateway/relay/adapter.py | 66 ++++++++++++++++++- .../relay/test_relay_slack_prompt_dm_root.py | 60 +++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index fff21bb6a94b5..8b125a22adca6 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -82,6 +82,10 @@ class RelayAdapter(BasePlatformAdapter): # synthetic reply_to in _resolve_thread_ts; the relay lane needs the same # disambiguation, and it needs the chat_type to know a chat is a DM. self._chat_type_by_chat: Dict[str, str] = {} + # chat_id -> last triggering message ts (Slack). The typing/status + # lane's synthetic thread anchor in thread-per-message mode (QA-1); + # see _capture_scope and send_typing. + self._last_inbound_ts_by_chat: Dict[str, str] = {} # chat_id -> the UNDERLYING platform (e.g. "discord", "telegram") this # chat belongs to (Phase 1.5 multi-platform-per-agent). One relay adapter # fronts N platforms on one WS; an outbound reply must egress through the @@ -378,6 +382,19 @@ class RelayAdapter(BasePlatformAdapter): chat_type = getattr(src, "chat_type", None) if chat_type: self._chat_type_by_chat[str(chat)] = str(chat_type) + # Triggering message ts (QA-1): the typing/status lane's metadata + # (base.py _thread_metadata_for_source) carries NO thread anchor + # for a top-level DM, but in thread-per-message mode the status + # must target the per-message thread (its root = this ts). Cache + # it per chat so send_typing can synthesize the anchor, mirroring + # native send_typing's _resolve_thread_ts(metadata.message_id). + # NOTE: message_id lives on the EVENT (MessageEvent), not the + # source — fall back to source for defensive coverage. + message_id = getattr(event, "message_id", None) or getattr( + src, "message_id", None + ) + if message_id: + self._last_inbound_ts_by_chat[str(chat)] = str(message_id) except Exception: # noqa: BLE001 - scope tracking must never break inbound pass @@ -909,6 +926,33 @@ class RelayAdapter(BasePlatformAdapter): """ if self._transport is None: return + # Thread anchor for the status surface (QA-1). Slack's status line + # ("is thinking…" in the thread's replies footer — works with plain + # chat:write, confirmed on native no-assistant bots) is THREAD-only: + # the connector's typing case no-ops without a thread_ts. But the + # typing lane's metadata (base.py _thread_metadata_for_source) has no + # anchor for a top-level DM — source.thread_id is None — so every + # heartbeat was silently dropped. In thread-per-message mode the + # turn's thread root IS the triggering message ts (run.py's synthetic + # root); synthesize it here from the per-chat inbound cache, exactly + # like native send_typing resolves thread_ts from metadata.message_id. + # Flat mode (reply_in_thread=false) keeps the no-anchor no-op: there + # is no thread and must not be one (#18859). + md = dict(metadata or {}) + if ( + not (md.get("thread_id") or md.get("thread_ts")) + and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value + and self._chat_type_by_chat.get(str(chat_id)) == "dm" + ): + try: + reply_in_thread = bool( + (self.config.extra or {}).get("reply_in_thread", True) + ) + except Exception: # noqa: BLE001 - config shape is adapter-owned + reply_in_thread = True + anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) + if reply_in_thread and anchor: + md["thread_id"] = anchor # Rich status parity (QA-1): run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). # Carry it as the typing frame's content so the connector's Slack @@ -921,7 +965,7 @@ class RelayAdapter(BasePlatformAdapter): frame: Dict[str, Any] = { "op": "typing", "chat_id": chat_id, - "metadata": self._with_scope(chat_id, metadata), + "metadata": self._with_scope(chat_id, md), } phrase = getattr(self, "_status_text", {}).get(str(chat_id)) if phrase: @@ -957,13 +1001,31 @@ class RelayAdapter(BasePlatformAdapter): platform = self._platform_by_chat.get(str(chat_id)) if platform != Platform.SLACK.value: return + # Clear must target the SAME thread the heartbeat set (QA-1): apply + # the identical synthetic-anchor rule as send_typing, or the clear + # frame no-ops threadless and the status line sticks until Slack's + # own timeout. + md = dict(metadata or {}) + if ( + not (md.get("thread_id") or md.get("thread_ts")) + and self._chat_type_by_chat.get(str(chat_id)) == "dm" + ): + try: + reply_in_thread = bool( + (self.config.extra or {}).get("reply_in_thread", True) + ) + except Exception: # noqa: BLE001 - config shape is adapter-owned + reply_in_thread = True + anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) + if reply_in_thread and anchor: + md["thread_id"] = anchor try: await self._transport.send_outbound( { "op": "typing", "chat_id": chat_id, "content": "", - "metadata": self._with_scope(chat_id, metadata), + "metadata": self._with_scope(chat_id, md), }, platform=platform, ) diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index 5e48e972b8dec..2656cf32c0365 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -246,3 +246,63 @@ async def test_typing_carries_live_status_phrase(): assert "content" not in typing[-1], ( "cleared phrase must omit content (empty string means CLEAR on Slack)" ) + + +# --------------------------------------------------------------------------- +# QA-1 status thread anchor: typing frames synthesize the per-message thread +# root in thread-per-message mode (the status line is thread-only on Slack). +# --------------------------------------------------------------------------- +def _wire_with_ts(chat_id, chat_type, message_id, **kw): + adapter, stub = _wire(chat_id, chat_type, **kw) + src = SessionSource( + platform=Platform.SLACK, chat_id=chat_id, chat_type=chat_type, + user_id="U1", scope_id=kw.get("scope_id"), + ) + ev = MessageEvent( + text="hi", source=src, message_type=MessageType.TEXT, message_id=message_id + ) + adapter._capture_scope(ev) + return adapter, stub + + +@pytest.mark.asyncio +async def test_typing_synthesizes_thread_anchor_in_thread_mode(): + """Top-level DM turn, thread-per-message mode: the typing frame gains the + triggering ts as thread_id so the connector's setStatus targets the + per-message thread instead of no-oping threadless.""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + await adapter.send_typing("D1", metadata=None) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042" + + +@pytest.mark.asyncio +async def test_typing_keeps_no_anchor_in_flat_mode(): + """Flat mode: no synthetic thread for the status either (#18859).""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + adapter.config.extra = {"reply_in_thread": False} + await adapter.send_typing("D1", metadata=None) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and "thread_id" not in typing[-1]["metadata"] + + +@pytest.mark.asyncio +async def test_typing_honours_real_thread_anchor(): + """Metadata that already names a thread wins over the synthetic cache.""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + await adapter.send_typing("D1", metadata={"thread_id": "1699.9000"}) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing[-1]["metadata"]["thread_id"] == "1699.9000" + + +@pytest.mark.asyncio +async def test_stop_typing_clear_targets_same_synthesized_thread(): + """The clear frame targets the same synthesized thread as the heartbeat + (else the status line sticks).""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + await adapter.send_typing("D1", metadata=None) + await adapter.stop_typing("D1", metadata=None) + clears = [ + f for f in stub.sent if f["op"] == "typing" and f.get("content") == "" + ] + assert clears and clears[-1]["metadata"].get("thread_id") == "1700.0042" From a51a17ebe30960f79c7c1cddd2225e5215e27681 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 15:56:10 +0000 Subject: [PATCH 18/36] fix(relay): promote the surviving reply_to anchor into metadata.thread_id on Slack sends (QA-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector's Slack sender threads on metadata ONLY: threadTs() reads metadata.thread_id/thread_ts and never the frame's reply_to. base.py's final-reply lane (and its stream-fallback 'first response' resend) builds metadata from source.thread_id — None for a top-level DM — so its sends carried reply_to as the sole threading signal and posted to the home channel (2026-07-27 post-approval report; the 15:17:03 frame showed meta_keys=['notify','user_id']). After the QA-6 mode gate keeps the anchor, copy it into metadata.thread_id so the wire carries the signal where the connector reads it. Flat mode unaffected (anchor already nulled); explicit thread metadata wins; non-Slack untouched. --- gateway/relay/adapter.py | 18 ++++++++++++++++++ .../relay/test_relay_slack_dm_streaming.py | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 8b125a22adca6..4fcdb1a2ac5a6 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -784,6 +784,24 @@ class RelayAdapter(BasePlatformAdapter): ) if effective_reply_to is None and reply_to is not None: send_metadata.pop("reply_to_message_id", None) + # QA-7: the connector's Slack sender THREADS ON METADATA ONLY — + # threadTs() reads metadata.thread_id/thread_ts and never looks at + # the frame's reply_to. A send whose only threading signal is + # reply_to (base.py's final-reply and fallback lanes build metadata + # from source.thread_id = None for a top-level DM) would post to the + # home channel even though _resolve_reply_to_for_send kept the + # anchor. Promote the surviving anchor into metadata.thread_id so + # the wire carries it where the connector actually reads it. Only + # when the mode gate kept the anchor (thread-per-message / real + # thread) — flat mode already nulled effective_reply_to above. + if ( + effective_reply_to is not None + and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value + and not ( + send_metadata.get("thread_id") or send_metadata.get("thread_ts") + ) + ): + send_metadata["thread_id"] = str(effective_reply_to) result = await self._transport.send_outbound( { "op": "send", diff --git a/tests/gateway/relay/test_relay_slack_dm_streaming.py b/tests/gateway/relay/test_relay_slack_dm_streaming.py index 35b3d5f989cc4..2438609d89ca5 100644 --- a/tests/gateway/relay/test_relay_slack_dm_streaming.py +++ b/tests/gateway/relay/test_relay_slack_dm_streaming.py @@ -87,6 +87,10 @@ async def test_slack_dm_reply_keeps_anchor_in_thread_per_message_mode(): assert frame["reply_to"] == "1700.0001", ( "thread-per-message: the triggering ts anchors the final reply" ) + # QA-7: the connector's Slack sender threads on metadata.thread_id ONLY + # (threadTs() never reads the frame's reply_to), so the surviving anchor + # must be promoted into metadata for the send to actually thread. + assert (frame["metadata"] or {}).get("thread_id") == "1700.0001" @pytest.mark.asyncio From e19ac3b7458c6290419d4e6a0f86152771814841 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 28 Jul 2026 08:19:56 +1000 Subject: [PATCH 19/36] docs(sync): point users from personal sync to org sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes sync` gave no hint that org sharing exists, so there was no path from 'I want to share this with my team' to `hermes skills propose` — sync looked like the only sharing surface while being personal-only (it always CAS-es refs/user//HEAD). - Bare `hermes sync` usage and the `--help` epilog now state that these commands are personal-only and name `hermes skills propose ` as the org path, noting the approval step and that org skills arrive automatically and are read-only locally. - Scrubbed the remaining internal jargon from the sync module docstring (M1-D, DEV-PHASE, HSP/1) missed by the earlier pass, which only covered help= strings. Tests: 2 new guards asserting both surfaces reference the org command. 373 passed / 0 failed via scripts/run_tests.sh. Both outputs verified by running the actual commands. --- hermes_cli/main.py | 6 +++- hermes_cli/subcommands/sync.py | 34 ++++++++++++++++++---- tests/agent/test_org_skill_namespace.py | 38 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 86d0d5eed6e03..7124009c14353 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4455,7 +4455,11 @@ def cmd_sync(args): " now Reconcile now: pull then push\n" " enable Opt a skill into sync\n" " disable Opt a skill out of sync\n" - " device [--name N] Show or set this device's sync label", + " device [--name N] Show or set this device's sync label\n" + "\n" + "These cover your PERSONAL skills, across your own devices.\n" + "To share a skill with your organisation instead:\n" + " hermes skills propose ", file=sys.stderr, ) return 1 diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index f752aa288cb7f..bdc771d282da2 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -1,4 +1,4 @@ -"""``hermes sync`` subcommand parser (HSP/1 personal skill sync). +"""``hermes sync`` subcommand parser (personal skill sync). Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape (``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance). @@ -8,13 +8,18 @@ Commands: hermes sync pull -- pull the owner's HEAD, materialize opted-in skills hermes sync push -- push opted-in skills to the owner's HEAD hermes sync now -- pull then push (full reconcile) - hermes sync enable -- opt a skill into sync (M1-D) + hermes sync enable -- opt a skill into sync hermes sync disable -- opt a skill out of sync hermes sync device [--name] -- show or set this device's sync label -Sync is INERT unless the resolved Nous token carries the DEV-PHASE gate claim -(tool_gateway_admin) AND a sync base URL is configured. The commands report -that state rather than failing opaquely. +This surface is PERSONAL sync only: it moves your own skills between your own +devices via ``refs/user//HEAD``. Sharing a skill with an organisation +is a different operation with a different destination and an approval step — +see ``hermes skills propose``. + +Sync is INERT unless the resolved Nous token carries the access-gate claim +AND a sync base URL is configured. The commands report that state rather than +failing opaquely. """ from __future__ import annotations @@ -24,10 +29,27 @@ from typing import Callable def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: """Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``.""" + import argparse + sync_parser = subparsers.add_parser( "sync", help="Personal skill sync across your devices", - description="Sync agent-created and user-authored skills across devices.", + description=( + "Sync agent-created and user-authored skills across your own " + "devices." + ), + epilog=( + "Sharing with your team:\n" + " These commands cover your PERSONAL skills only. To share a " + "skill with your\n" + " organisation, use `hermes skills propose ` instead — it " + "submits the\n" + " skill to your org's shared set (an admin approves it unless " + "you are one).\n" + " Approved org skills arrive automatically and are read-only " + "locally.\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) sync_sub = sync_parser.add_subparsers(dest="sync_command") diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index 9a4c7783f33fc..3c6ed7db6fee8 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -237,3 +237,41 @@ class TestOrgPullIsWiredIn: assert not banned.search(line), ( f"{path.name}:{i} leaks internal jargon to users: {line.strip()}" ) + + +class TestOrgSharingIsDiscoverable: + """`hermes sync` must point users at the org-sharing command. + + Without this, there is no path from "I want to share this with my team" + to `hermes skills propose` — sync looks like the only sharing surface + while being personal-only. + """ + + def test_sync_usage_block_mentions_propose(self): + import pathlib + + main_src = ( + pathlib.Path(__file__).resolve().parents[2] + / "hermes_cli" + / "main.py" + ).read_text(encoding="utf-8") + usage_start = main_src.index( + "usage: hermes sync " + ) + usage_block = main_src[usage_start : usage_start + 1200] + assert "hermes skills propose" in usage_block, ( + "`hermes sync` usage must point at the org-sharing command." + ) + + def test_sync_parser_epilog_mentions_propose(self): + import pathlib + + src = ( + pathlib.Path(__file__).resolve().parents[2] + / "hermes_cli" + / "subcommands" + / "sync.py" + ).read_text(encoding="utf-8") + assert "hermes skills propose" in src, ( + "`hermes sync --help` must point at the org-sharing command." + ) From 981feb673055f9c45e98887af4dec1be462bfda6 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 28 Jul 2026 09:06:34 +1000 Subject: [PATCH 20/36] feat(skills): org skills are editable in place; local edits survive org updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-only org mirror broke the learning loop precisely where it matters most. The system prompt tells every agent to patch a skill the moment it finds a gap, and shared skills are the ones the most people use — but every write to _org/ was refused, and the curator was excluded from them outright. So org skills froze while personal skills kept improving, and the offered alternative ("fork it into a personal skill, then propose the fork") is not something an agent does mid-task. The refusal WAS the feature; improvements were simply lost, and manual forks would have fragmented the shared set. Edit in place: - skill_manage patch/edit/write_file now work on org skills. Only delete is still refused (the mirror is a view of org HEAD — a local delete returns on the next pull; removing a shared skill is an admin action). - Org skills are curation-eligible again, so the curator can improve the highest-leverage skills in the system instead of skipping them. - The load-time provenance header now says edits are allowed and kept, instead of instructing the agent not to edit. Local edits are never overwritten: - pull_org_skills previously rmtree'd each skill dir and re-materialized it, silently destroying local work on the next session start. It now records a content fingerprint per skill (.org-baseline.json) when it writes one, and SKIPS any skill whose local content diverges from that baseline. - When upstream ALSO changed such a skill, it is reported in the pull result's "conflicted" list and left untouched for the user to resolve deliberately (propose the local version, or delete it and re-pull to take theirs). A missing baseline is treated as unmodified so pre-existing mirrors do not raise phantom conflicts. - Fingerprints are content-based (path + bytes, sorted), so a touch/mtime change is not mistaken for an edit. Sharing back: - Default: the edit stays local and the tool result tells the user to run "hermes skills propose ". - Opt-in sync.org_auto_propose / HERMES_SYNC_ORG_AUTO_PROPOSE submits each edit immediately. Defaults OFF — pushing every agent edit to a whole organisation is not a safe default. A failed submission never fails the edit; the change is saved and can be proposed later. - "hermes sync status" lists org skills with unshared local edits; "hermes sync pull" reports conflicts it declined to overwrite. Tests: 25 in the namespace suite (was 15). The two that asserted the old read-only behaviour now assert the opposite. New coverage for edit-applied, share-back guidance, delete-still-refused, curation-allowed, edit detection, missing-baseline tolerance, mtime-insensitivity, and the auto-propose default. 428 passed / 0 failed via scripts/run_tests.sh. Verified through the REAL pull path against a mock plane: pull v1 -> edit in place -> upstream ships v2 -> pull leaves the local edit intact, reports the conflict, and surfaces it in status. --- agent/skill_utils.py | 3 + hermes_cli/main.py | 26 ++++- tests/agent/test_org_skill_namespace.py | 132 +++++++++++++++++++++-- tools/skill_manager_tool.py | 89 +++++++++++++--- tools/skill_usage.py | 12 +-- tools/skills_sync_client.py | 133 +++++++++++++++++++++++- tools/skills_tool.py | 20 ++-- 7 files changed, 370 insertions(+), 45 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index ea4bb2ea27d23..9276f145abddc 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -62,6 +62,9 @@ SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) ORG_MIRROR_DIR_NAME = "_org" ORG_ACTIVE_MARKER = ".active_org" ORG_PROVENANCE_FILE = ".org-provenance.json" +# Records the fingerprint of each skill exactly as upstream sent it, so a +# later local edit is detectable and an org pull can refuse to clobber it. +ORG_BASELINE_FILE = ".org-baseline.json" def read_active_org_id(skills_dir: Path) -> Optional[str]: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7124009c14353..ac374175b6bc6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4508,12 +4508,21 @@ def cmd_sync(args): print(_json.dumps(status, indent=2, ensure_ascii=False)) if status.get("org_available"): n = len(status.get("org_skills") or []) + modified = status.get("org_skills_modified") or [] print( - f"\nOrg skills: {n} shared skill(s) mirrored read-only from " - f"your organisation (your role: {status.get('org_role')}). " - f"They load alongside your own, labeled by origin.", + f"\nOrg skills: {n} shared skill(s) from your organisation " + f"(your role: {status.get('org_role')}). They load alongside " + f"your own, labeled by origin, and you can edit them.", file=sys.stderr, ) + if modified: + print( + f" {len(modified)} with local edits not yet shared: " + f"{', '.join(modified)}\n" + f" Share them back with `hermes skills propose `. " + f"Org updates will not overwrite them.", + file=sys.stderr, + ) elif status.get("logged_in"): print( "\nOrg skills: not applicable — this account isn't a member " @@ -4574,6 +4583,17 @@ def cmd_sync(args): f"organisation.", file=sys.stderr, ) + clashes = org_result.get("conflicted") or [] + if clashes: + print( + f"org: {len(clashes)} skill(s) have BOTH local edits " + f"and org updates, so they were left as-is: " + f"{', '.join(clashes)}\n" + f" Your local version is intact. Review it, then " + f"either propose it or delete the local copy and pull " + f"again to take the org version.", + file=sys.stderr, + ) elif sub == "push": result = ssc.push_skills(identity=identity, message="hermes sync push") elif sub == "now": diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index 3c6ed7db6fee8..b86d9a7dad9b6 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -150,30 +150,61 @@ class TestListingCollisionsAndLabels: assert "[name collision" not in out -class TestOrgMirrorReadOnly: - def test_skill_manage_patch_refuses_org_mirror(self, tmp_path, monkeypatch): +class TestOrgSkillsAreEditableInPlace: + """The learning loop must work ON shared skills, not around them. + + Refusing edits to `_org/` froze exactly the skills the most people use: + the agent is instructed to patch a skill the moment it finds a gap, and + "fork it to a personal skill first" is not something an agent does + mid-task. So edits land in place; org updates never clobber them; the + user (or auto-propose) shares them back. + """ + + def _org_skill(self, tmp_path, monkeypatch): from tools import skill_manager_tool as smt + from agent import skill_utils as _sku skills = tmp_path / "skills" - _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + d = _mk_skill( + skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x" + ) _mark_active(skills, "org-1") monkeypatch.setattr(smt, "_skills_dir", lambda: skills) - from agent import skill_utils as _sku monkeypatch.setattr( _sku, "get_all_skills_dirs", lambda: [skills], raising=True ) - result = smt._patch_skill("shared-x", "body", "hacked") - assert result["success"] is False - assert "ORG-SHARED" in result["error"] - assert "propose" in result["error"] + return smt, skills, d - def test_curation_exempt(self, tmp_path, monkeypatch): + def test_patch_is_allowed_and_applied(self, tmp_path, monkeypatch): + smt, _skills, d = self._org_skill(tmp_path, monkeypatch) + result = smt._patch_skill("shared-x", "body", "improved") + assert result["success"] is True, result.get("error") + assert "improved" in (d / "SKILL.md").read_text(encoding="utf-8") + + def test_edit_tells_the_user_how_to_share_it_back(self, tmp_path, monkeypatch): + smt, _skills, _d = self._org_skill(tmp_path, monkeypatch) + result = smt._patch_skill("shared-x", "body", "improved") + # Without auto-propose the edit stays local, and the tool result must + # say so AND name the command — otherwise the improvement is stranded. + assert "propose" in (result.get("org_sharing") or "") + + def test_delete_is_still_refused(self, tmp_path, monkeypatch): + smt, _skills, d = self._org_skill(tmp_path, monkeypatch) + guard = smt._org_mirror_write_guard("shared-x", d, "delete") + assert guard is not None and guard["success"] is False + assert "admin" in guard["error"] + + def test_curation_is_allowed(self, tmp_path, monkeypatch): from tools import skill_usage as su skills = tmp_path / "skills" - d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") + d = _mk_skill( + skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x" + ) monkeypatch.setattr(su, "_skills_dir", lambda: skills) - assert su.is_curation_eligible("shared-x", d) is False + # The curator must be able to improve shared skills — they are the + # highest-leverage ones in the system. + assert su.is_curation_eligible("shared-x", d) is True class TestOrgPullIsWiredIn: @@ -275,3 +306,82 @@ class TestOrgSharingIsDiscoverable: assert "hermes skills propose" in src, ( "`hermes sync --help` must point at the org-sharing command." ) + + +class TestLocalEditsSurviveOrgUpdates: + """Ben's requirement: local edits are never silently overwritten. + + An org pull materializes the shared set. Before this, it `rmtree`'d each + skill dir and re-wrote it, so any local improvement vanished on the next + session start with no warning. Now a locally-modified skill is skipped + and reported as a conflict for the user to resolve deliberately. + """ + + def _mirror(self, tmp_path, monkeypatch, body="original\n"): + from tools import skills_sync_client as ssc + + skills = tmp_path / "skills" + d = _mk_skill( + skills, + f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", + name="shared-x", + body=body, + ) + _mark_active(skills, "org-1") + monkeypatch.setattr(ssc, "_skills_dir", lambda: skills) + monkeypatch.setattr( + ssc, "_org_dir", lambda: skills / sku.ORG_MIRROR_DIR_NAME + ) + return ssc, skills, d + + def test_unmodified_skill_is_not_flagged(self, tmp_path, monkeypatch): + ssc, _skills, d = self._mirror(tmp_path, monkeypatch) + ssc._write_org_baseline( + "org-1", + {"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}}, + ) + assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False + assert ssc.list_locally_modified_org_skills("org-1") == [] + + def test_edited_skill_is_detected(self, tmp_path, monkeypatch): + ssc, _skills, d = self._mirror(tmp_path, monkeypatch) + ssc._write_org_baseline( + "org-1", + {"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}}, + ) + (d / "SKILL.md").write_text("---\nname: shared-x\n---\nEDITED\n", encoding="utf-8") + assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is True + assert ssc.list_locally_modified_org_skills("org-1") == ["shared-x"] + + def test_missing_baseline_does_not_cry_wolf(self, tmp_path, monkeypatch): + ssc, _skills, _d = self._mirror(tmp_path, monkeypatch) + # Mirror pulled before baselines existed — must not be reported as + # modified (that would block every update with a phantom conflict). + assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False + + def test_fingerprint_is_content_based_not_mtime(self, tmp_path, monkeypatch): + import os + import time + + ssc, _skills, d = self._mirror(tmp_path, monkeypatch) + before = ssc._skill_dir_fingerprint(d) + time.sleep(0.01) + os.utime(d / "SKILL.md", None) # touch: mtime changes, content doesn't + assert ssc._skill_dir_fingerprint(d) == before + + def test_auto_propose_defaults_off(self, monkeypatch): + from tools import skills_sync_client as ssc + + monkeypatch.delenv("HERMES_SYNC_ORG_AUTO_PROPOSE", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {}, raising=False + ) + # Default must be OFF: silently pushing every agent edit to the whole + # organisation is not a safe default. + assert ssc.sync_org_auto_propose() is False + + def test_auto_propose_can_be_enabled_by_env(self, monkeypatch): + from tools import skills_sync_client as ssc + + monkeypatch.setenv("HERMES_SYNC_ORG_AUTO_PROPOSE", "1") + assert ssc.sync_org_auto_propose() is True diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index ffb88deb33773..5064b25a048ca 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -622,14 +622,61 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]: return None -def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optional[Dict[str, Any]]: - """Refuse writes to org-mirror skills (M2, contract §11.11 / design §7.1). +def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]: + """Submit an org-skill edit upstream when `sync.org_auto_propose` is on. - The ``_org/`` mirror is materialized FROM the org HEAD and overwritten on - every pull — a local edit would be silently lost AND would misrepresent - admin-approved shared content. The change path is: fork into a personal - skill, edit, then ``hermes skills propose``. + Returns a short note for the tool result, or None when nothing happened. + Never raises: an offline/failed submission must not fail the edit itself — + the change is already saved locally and can be proposed later. """ + try: + from agent.skill_utils import is_org_mirror_path + from tools import skills_sync_client as ssc + + if not is_org_mirror_path(skill_path, _skills_dir()): + return None + if not ssc.sync_org_auto_propose(): + return ( + f"This skill is shared by your organisation. Your edit is " + f"saved locally and will not be overwritten by org updates. " + f"Run `hermes skills propose {name}` to share it back." + ) + result = ssc.propose_skill(name) + if result.get("proposal_pending"): + return ( + f"Auto-proposed to your organisation as proposal " + f"#{result.get('proposal_id')} (pending admin review)." + ) + return "Auto-proposed to your organisation (merged into the shared set)." + except Exception as e: + logger.debug("auto-propose skipped for %s: %s", name, e) + return ( + f"Edit saved locally. Could not submit it to your organisation " + f"right now — run `hermes skills propose {name}` to retry." + ) + + +def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optional[Dict[str, Any]]: + """Org-shared skills are EDITABLE IN PLACE — this only blocks deletion. + + Earlier versions refused every write to `_org/`, which broke the learning + loop exactly where it matters most: the agent is told to patch a skill the + moment it finds a gap, and shared skills are the ones the most people use. + Blocking that froze org skills while personal ones kept improving, and the + "fork it into a personal skill" alternative is not something an agent does + mid-task — so improvements were simply lost. + + Now an edit lands in the mirror and is protected from being overwritten by + the next org pull (see the baseline sidecar in skills_sync_client). It + reaches the organisation when the user runs `hermes skills propose`, or + immediately if `sync.org_auto_propose` is on. + + Deletion is still refused: the mirror is a materialized view of the org + HEAD, so a local delete is meaningless (the next pull restores it) and + removing a skill for the organisation is an admin action, not a local one. + """ + if action not in {"delete", "remove_file"}: + return None try: from agent.skill_utils import is_org_mirror_path @@ -637,12 +684,12 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona return { "success": False, "error": ( - f"Refusing {action} for '{name}': it is an ORG-SHARED " - "skill (read-only mirror of your org's approved set; " - "local edits are overwritten on every org pull). To " - "change it: copy it to a personal skill, edit that, then " - "`hermes skills propose ` so an org admin can " - "review and approve." + f"Cannot {action} '{name}' locally: it is shared by your " + "organisation, so a local delete would just come back on " + "the next sync. Ask an org admin to remove it for " + "everyone. (Editing it IS allowed — your changes are kept " + "and can be proposed back with `hermes skills propose " + f"{name}`.)" ), } except Exception: @@ -954,12 +1001,17 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: except Exception: pass - return { + result = { "success": True, "message": f"Skill '{name}' updated (full rewrite).", "path": str(existing["path"]), "_change": {"description": _desc}, } + org_note = _maybe_auto_propose_org_edit(name, existing["path"]) + if org_note: + result["org_sharing"] = org_note + result["message"] = f"{result['message']} {org_note}" + return result def _patch_skill( @@ -1075,6 +1127,10 @@ def _patch_skill( "old": old_string[:200] + ("…" if len(old_string) > 200 else ""), "new": new_string[:200] + ("…" if len(new_string) > 200 else ""), } + org_note = _maybe_auto_propose_org_edit(name, skill_dir) + if org_note: + result["org_sharing"] = org_note + result["message"] = f"{result['message']} {org_note}" return result @@ -1244,11 +1300,16 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: target.unlink(missing_ok=True) return {"success": False, "error": scan_error} - return { + result = { "success": True, "message": f"File '{file_path}' written to skill '{name}'.", "path": str(target), } + org_note = _maybe_auto_propose_org_edit(name, existing["path"]) + if org_note: + result["org_sharing"] = org_note + result["message"] = f"{result['message']} {org_note}" + return result def _remove_file(name: str, file_path: str) -> Dict[str, Any]: diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 8c0fc848a47a4..7ea57d5480a86 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -450,18 +450,16 @@ def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> Agent-created skills are always eligible. Bundled built-ins become eligible only when ``curator.prune_builtins`` is enabled. Hub-installed and external skill-dir skills are NEVER eligible — they have an external upstream owner. - Org-mirror skills (``_org/``) are NEVER eligible — the org HEAD owns them; - curation happens via propose → approve, not local archive/consolidate. + Org-shared skills ARE eligible for improvement (the curator may patch them + like any other skill; edits stay local until proposed) but are protected + from ARCHIVE/DELETE elsewhere — removing a shared skill is an org-admin + action, not a local curation decision. Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of any flag — they back load-bearing UX and must never be archived or consolidated. """ - from agent.skill_utils import is_org_mirror_path - if skill_path is not None and is_external_skill_path(skill_path): return False - if skill_path is not None and is_org_mirror_path(skill_path, _skills_dir()): - return False if is_protected_builtin(skill_name): return False if is_hub_installed(skill_name): @@ -470,8 +468,6 @@ def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> return _prune_builtins_enabled() local_dir = _find_skill_dir(skill_name) if local_dir is not None: - if is_org_mirror_path(local_dir, _skills_dir()): - return False return not is_external_skill_path(local_dir) if _find_external_skill_dir(skill_name) is not None: return False diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index daa582f8c28d5..0bc604ac12e12 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -377,6 +377,25 @@ def sync_feature_enabled() -> bool: return _sync_config_bool("HERMES_SYNC_ENABLED", "enabled", default=False) +def sync_org_auto_propose() -> bool: + """Whether an agent/user edit to an org skill is proposed automatically. + + ``HERMES_SYNC_ORG_AUTO_PROPOSE`` -> ``sync.org_auto_propose`` -> False. + + False (default): edits to an org-shared skill stay LOCAL until the user + runs ``hermes skills propose ``. The skill keeps working with the + edit applied; the organisation just doesn't see it yet. + + True: every local edit to an org skill is submitted to the org as a + proposal right away (an admin still approves it, unless the editor is an + admin). Suits a small, high-trust team that wants improvements to flow + back without anyone remembering to push them. + """ + return _sync_config_bool( + "HERMES_SYNC_ORG_AUTO_PROPOSE", "org_auto_propose", default=False + ) + + def sync_default_opt_in() -> bool: """The personal sync default opt-in policy (env-first). @@ -1565,6 +1584,8 @@ def sync_status() -> Dict[str, Any]: "org_id": None, "org_role": None, "org_skills": [], + # Org skills edited locally and not yet shared back. + "org_skills_modified": [], } try: identity = resolve_identity() @@ -1586,6 +1607,9 @@ def sync_status() -> Dict[str, Any]: status["org_id"] = org_identity.get("org_id") status["org_role"] = org_identity.get("org_role") status["org_skills"] = list_org_skill_names() + status["org_skills_modified"] = list_locally_modified_org_skills( + status["org_id"] + ) except SyncInertError: pass except Exception as e: @@ -1724,15 +1748,34 @@ def pull_org_skills( dest_root = _org_dir() / org_id updated: List[str] = [] + # Skills the user/agent has edited locally and upstream also changed. + # We do NOT overwrite them — the local work wins until the user resolves. + conflicted: List[str] = [] + baseline = _read_org_baseline(org_id) for rel_path, tree_hash in sorted(skill_trees.items()): dest = dest_root / PurePosixPath(rel_path) try: if dest.exists(): + # Local edits are protected: never clobber work the user or + # agent did in place. Skip the update and report it so they + # can resolve deliberately (propose the local version, or + # discard it and re-pull). + if org_skill_is_locally_modified(rel_path, org_id): + prev = baseline.get(rel_path) or {} + # Upstream also moved on => a real conflict the user must + # resolve. Upstream unchanged => their edit simply stands. + if prev.get("tree") != tree_hash: + conflicted.append(rel_path) + continue import shutil shutil.rmtree(dest) dest.mkdir(parents=True, exist_ok=True) materialize_tree(client, tree_hash, dest) + baseline[rel_path] = { + "fingerprint": _skill_dir_fingerprint(dest), + "tree": tree_hash, + } updated.append(rel_path) except Exception as e: logger.warning( @@ -1754,7 +1797,95 @@ def pull_org_skills( "skills": updated, }, ) - return {"ok": True, "org_id": org_id, "head": head, "updated": updated} + _write_org_baseline(org_id, baseline) + if conflicted: + logger.warning( + "skills_sync_client: %d org skill(s) have local edits AND upstream " + "changes; left untouched: %s", + len(conflicted), + ", ".join(conflicted), + ) + return { + "ok": True, + "org_id": org_id, + "head": head, + "updated": updated, + "conflicted": conflicted, + } + + +def _skill_dir_fingerprint(path: Path) -> str: + """Stable content hash of a materialized skill directory. + + Used to tell "the user/agent edited this org skill" from "this is exactly + what upstream shipped". Hashes every file's relative path + bytes, sorted, + so it is independent of filesystem ordering and mtimes. + """ + h = hashlib.sha256() + try: + for f in sorted(p for p in path.rglob("*") if p.is_file()): + h.update(str(f.relative_to(path)).replace("\\", "/").encode("utf-8")) + h.update(b"\0") + h.update(f.read_bytes()) + h.update(b"\0") + except OSError as e: + logger.debug("skills_sync_client: fingerprint failed for %s: %s", path, e) + return "" + return h.hexdigest() + + +def _org_baseline_path(org_id: str) -> Path: + """Sidecar recording the upstream fingerprint of each mirrored skill.""" + from agent.skill_utils import ORG_BASELINE_FILE + + return _org_dir() / org_id / ORG_BASELINE_FILE + + +def _read_org_baseline(org_id: str) -> Dict[str, Any]: + try: + return json.loads(_org_baseline_path(org_id).read_text(encoding="utf-8")) + except Exception: + return {} + + +def _write_org_baseline(org_id: str, baseline: Dict[str, Any]) -> None: + try: + p = _org_baseline_path(org_id) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(baseline, indent=2, sort_keys=True), encoding="utf-8") + except Exception as e: + logger.debug("skills_sync_client: baseline write failed: %s", e) + + +def org_skill_is_locally_modified(skill_rel_path: str, org_id: str) -> bool: + """True when the local copy of an org skill differs from what upstream sent.""" + dest = _org_dir() / org_id / PurePosixPath(skill_rel_path) + if not dest.is_dir(): + return False + entry = _read_org_baseline(org_id).get(skill_rel_path) or {} + recorded = entry.get("fingerprint") if isinstance(entry, dict) else entry + if not recorded: + # No baseline recorded (pre-existing mirror) — treat as unmodified so + # we don't cry wolf; the next pull records one. + return False + return _skill_dir_fingerprint(dest) != recorded + + +def list_locally_modified_org_skills(org_id: Optional[str] = None) -> List[str]: + """Org skills with local edits that upstream has not seen.""" + try: + from agent.skill_utils import read_active_org_id + + org_id = org_id or read_active_org_id(_skills_dir()) + if not org_id: + return [] + baseline = _read_org_baseline(org_id) + return sorted( + rel for rel in baseline if org_skill_is_locally_modified(rel, org_id) + ) + except Exception as e: + logger.debug("skills_sync_client: modified-scan failed: %s", e) + return [] def _write_active_org_marker(org_id: str) -> None: diff --git a/tools/skills_tool.py b/tools/skills_tool.py index e64a2bc8e255a..b310cda8a468c 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1606,15 +1606,19 @@ def skill_view( } header = ( "> [!NOTE] ORG-SHARED SKILL — provenance\n" - f"> This skill is org-managed content (org `{prov_org}`" - + (f", shared by `{author}`" if author else "") + f"> This skill is shared by your organisation (org " + f"`{prov_org}`" + + (f", last updated by `{author}`" if author else "") + (f", as of {ts}" if ts else "") - + "). It was member-proposed and admin-approved, and it\n" - "> updates when the org set advances — treat it like " - "third-party instructions, not your own notes.\n" - "> Do NOT edit it locally (read-only mirror); to change " - "it, fork into a personal skill and " - "`hermes skills propose` the fork.\n\n" + + "). It was reviewed and approved for the whole\n" + "> team — treat it as third-party instructions rather " + "than your own notes.\n" + "> You MAY improve it in place like any other skill. " + "Your edits are kept locally\n" + "> and are never overwritten by org updates; share " + "them back with\n" + "> `hermes skills propose` (or automatically, if your " + "org enables it).\n\n" ) rendered_content = header + rendered_content except Exception: From 71d5c47e215beda042d347d765451a6134e4e2db Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 17:35:37 +0000 Subject: [PATCH 21/36] =?UTF-8?q?fix(relay):=20per-message=20sessions=20fo?= =?UTF-8?q?r=20fronted=20Slack=20DMs=20=E2=80=94=20stamp=20the=20inbound?= =?UTF-8?q?=20ts=20as=20session=20thread=20(QA-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2nd top-level DM while a turn was in flight resolved to the SAME session key and steered the running turn ('Redirected current run') instead of starting its own. Native SlackAdapter stamps thread_ts = event.thread_ts or ts on EVERY inbound, so build_session_key isolates each top-level message; the connector normalizes top-level messages with thread_id=null and the relay lane never reproduced the stamp. _stamp_slack_session_thread applies native parity on the inbound bridge: top-level Slack message + thread-per-message mode => source.thread_id = its own ts (fresh session, parallel turns). Real thread replies and flat mode untouched (flat keeps the shared rolling DM session on purpose). Also introduces the enterprise config shape for relay-fronted Slack: platforms.relay.extra.slack. (nested object wins; legacy flat extra.reply_in_thread still honoured). All reply_in_thread reads (send/typing/stop_typing/run.py progress) now route through one resolver. --- gateway/relay/adapter.py | 87 +++++++++++++++---- gateway/run.py | 19 +++- .../relay/test_relay_slack_prompt_dm_root.py | 69 +++++++++++++++ 3 files changed, 153 insertions(+), 22 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 4fcdb1a2ac5a6..c7eaa1a981c63 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -274,6 +274,7 @@ class RelayAdapter(BasePlatformAdapter): async def _on_inbound(self, event) -> None: """Bridge a connector-delivered MessageEvent into the normal adapter path.""" self._capture_scope(event) + self._stamp_slack_session_thread(event) # Phase 3: a structured prompt answer resolves its waiting primitive # (approval/confirm/clarify) and is CONSUMED — it must not also # dispatch as a chat message. Unknown/expired prompt ids fall through @@ -283,6 +284,71 @@ class RelayAdapter(BasePlatformAdapter): await self._localize_inbound_media(event) await self.handle_message(event) + def _relay_slack_extra(self) -> Dict[str, Any]: + """The Slack-behavior subset of the RELAY platform config. + + Enterprise knob shape (Hermes-config directed, relay-namespaced): + + platforms: + relay: + extra: + slack: # supported subset of native Slack fields + reply_in_thread: true + + The native ``platforms.slack`` block keeps meaning "native adapter + settings"; relay-fronted Slack reads its subset here. Legacy fallback: + a flat key on the relay extra (``extra.reply_in_thread``) still wins + when no ``slack`` object exists, preserving current staging configs. + """ + extra = getattr(self.config, "extra", None) or {} + sub = extra.get("slack") + return sub if isinstance(sub, dict) else extra + + def _effective_reply_in_thread(self) -> bool: + """Resolve the thread-per-message vs flat-DM mode for fronted Slack.""" + try: + return bool(self._relay_slack_extra().get("reply_in_thread", True)) + except Exception: # noqa: BLE001 - config shape is operator-owned + return True + + def _stamp_slack_session_thread(self, event) -> None: + """Native session-keying parity for fronted Slack (QA-3). + + Native SlackAdapter's inbound handler stamps ``thread_ts = + event.thread_ts or ts`` — every TOP-LEVEL message carries its own ts + as ``source.thread_id``, so build_session_key appends it and each + top-level message gets a FRESH session (per-message threads ⇒ + per-message sessions; a 2nd message runs parallel instead of steering + the in-flight turn). The connector normalizes a top-level message + with thread_id=null, so without this stamp every top-level DM + collapses into ONE session key and message 2 pre-empts message 1 + ("Redirected current run", 2026-07-27 report). + + Only in thread-per-message mode: flat mode keeps the shared rolling + DM session on purpose (steer/queue there is the intended UX). Never + overwrites a real thread_id (an in-thread reply must keep resolving + to its thread's session). + """ + try: + src = getattr(event, "source", None) + if not src: + return + platform = getattr(src, "platform", None) + if getattr(platform, "value", platform) != Platform.SLACK.value: + return + if getattr(src, "thread_id", None): + return # real thread — its session key is already correct + message_id = getattr(event, "message_id", None) or getattr( + src, "message_id", None + ) + if not message_id: + return + if not self._effective_reply_in_thread(): + return + src.thread_id = str(message_id) + except Exception: # noqa: BLE001 - session stamping must never break inbound + logger.debug("slack session-thread stamp failed", exc_info=True) + async def _localize_inbound_media(self, event) -> None: """Download connector re-hosted attachments to local temp paths. @@ -875,12 +941,7 @@ class RelayAdapter(BasePlatformAdapter): # message to the DM root while progress stayed threaded (2026-07-27 # report, sibling of the QA-5 prompt bug). Native SlackAdapter only # suppresses the anchor when reply_in_thread=false; mirror that. - try: - reply_in_thread = bool( - (self.config.extra or {}).get("reply_in_thread", True) - ) - except Exception: # noqa: BLE001 - config shape is adapter-owned - reply_in_thread = True + reply_in_thread = self._effective_reply_in_thread() if reply_in_thread: # Thread-per-message: the triggering ts is the thread anchor. return reply_to @@ -962,12 +1023,7 @@ class RelayAdapter(BasePlatformAdapter): and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - try: - reply_in_thread = bool( - (self.config.extra or {}).get("reply_in_thread", True) - ) - except Exception: # noqa: BLE001 - config shape is adapter-owned - reply_in_thread = True + reply_in_thread = self._effective_reply_in_thread() anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) if reply_in_thread and anchor: md["thread_id"] = anchor @@ -1028,12 +1084,7 @@ class RelayAdapter(BasePlatformAdapter): not (md.get("thread_id") or md.get("thread_ts")) and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - try: - reply_in_thread = bool( - (self.config.extra or {}).get("reply_in_thread", True) - ) - except Exception: # noqa: BLE001 - config shape is adapter-owned - reply_in_thread = True + reply_in_thread = self._effective_reply_in_thread() anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) if reply_in_thread and anchor: md["thread_id"] = anchor diff --git a/gateway/run.py b/gateway/run.py index aac6a192555f9..5e7f159011e43 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -20688,11 +20688,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _slack_adapter_for_progress = self._adapter_for_source(source) if _slack_adapter_for_progress is not None: try: - _progress_reply_in_thread = bool( - _slack_adapter_for_progress.config.extra.get( - "reply_in_thread", True - ) + # Relay lane: the adapter owns mode resolution (nested + # platforms.relay.extra.slack subset with flat-key + # fallback). Native lane: read the flat extra as before. + _mode_fn = getattr( + _slack_adapter_for_progress, + "_effective_reply_in_thread", + None, ) + if callable(_mode_fn): + _progress_reply_in_thread = bool(_mode_fn()) + else: + _progress_reply_in_thread = bool( + _slack_adapter_for_progress.config.extra.get( + "reply_in_thread", True + ) + ) except Exception: _progress_reply_in_thread = True _progress_thread_id = _resolve_progress_thread_id( diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index 2656cf32c0365..098b1fcaddebe 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -306,3 +306,72 @@ async def test_stop_typing_clear_targets_same_synthesized_thread(): f for f in stub.sent if f["op"] == "typing" and f.get("content") == "" ] assert clears and clears[-1]["metadata"].get("thread_id") == "1700.0042" + + +# --------------------------------------------------------------------------- +# QA-3 session keying: a top-level Slack DM message gets its own ts stamped as +# source.thread_id (native inbound parity) so each message keys a FRESH +# session in thread-per-message mode; flat mode and real threads untouched. +# --------------------------------------------------------------------------- +def _inbound_event(chat_id="D1", message_id="1700.0100", thread_id=None): + src = SessionSource( + platform=Platform.SLACK, chat_id=chat_id, chat_type="dm", + user_id="U1", scope_id="T1", thread_id=thread_id, + ) + return MessageEvent( + text="hi", source=src, message_type=MessageType.TEXT, + message_id=message_id, + ) + + +def test_top_level_dm_gets_session_thread_stamp(): + adapter, _ = _wire("D1", "dm") + ev = _inbound_event(message_id="1700.0100") + adapter._stamp_slack_session_thread(ev) + assert ev.source.thread_id == "1700.0100" + + +def test_two_top_level_messages_key_distinct_sessions(): + from gateway.session import build_session_key + adapter, _ = _wire("D1", "dm") + e1 = _inbound_event(message_id="1700.0100") + e2 = _inbound_event(message_id="1700.0200") + adapter._stamp_slack_session_thread(e1) + adapter._stamp_slack_session_thread(e2) + k1 = build_session_key(e1.source) + k2 = build_session_key(e2.source) + assert k1 != k2, "each top-level message must be its own session (QA-3)" + + +def test_real_thread_reply_keeps_its_thread_session(): + adapter, _ = _wire("D1", "dm") + ev = _inbound_event(message_id="1700.0300", thread_id="1700.0100") + adapter._stamp_slack_session_thread(ev) + assert ev.source.thread_id == "1700.0100", ( + "an in-thread reply must keep resolving to its thread's session" + ) + + +def test_flat_mode_keeps_shared_dm_session(): + adapter, _ = _wire("D1", "dm") + adapter.config.extra = {"reply_in_thread": False} + ev = _inbound_event(message_id="1700.0400") + adapter._stamp_slack_session_thread(ev) + assert ev.source.thread_id is None, ( + "flat mode: shared rolling DM session (steer/queue) is intended UX" + ) + + +def test_nested_relay_slack_config_subset_wins(): + """Enterprise knob shape: platforms.relay.extra.slack.reply_in_thread.""" + adapter, _ = _wire("D1", "dm") + adapter.config.extra = {"slack": {"reply_in_thread": False}} + assert adapter._effective_reply_in_thread() is False + adapter.config.extra = {"slack": {"reply_in_thread": True}} + assert adapter._effective_reply_in_thread() is True + # Legacy flat key still honoured when no nested object exists. + adapter.config.extra = {"reply_in_thread": False} + assert adapter._effective_reply_in_thread() is False + # Default: thread-per-message. + adapter.config.extra = {} + assert adapter._effective_reply_in_thread() is True From 9864e00fb42a4c7f49b2767dd58a8a0a1f832865 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 22:59:02 +0000 Subject: [PATCH 22/36] =?UTF-8?q?feat(relay):=20flat-DM=20liveliness=20?= =?UTF-8?q?=E2=80=94=20status=20anchors=20to=20the=20triggering=20ts,=20re?= =?UTF-8?q?plies=20stay=20flat=20(QA-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Victor's correction: flat DMs CAN have a live thinking status. setStatus on the triggering message's ts renders '… thinking'/per-tool phrases in that message's thread-footer space and clears without leaving a message artifact. Native suppresses this because ITS reply routing could inherit the activated thread; the relay lane's flat-mode sends strip their anchors explicitly (QA-6/7), so the status anchor cannot leak into reply placement — proven by the new leak-guard test. send_typing/stop_typing now anchor the status in flat mode too, gated by platforms.relay.extra.slack.flat_dm_status (default ON; false restores the fully anchorless posture). Thread mode unchanged. --- gateway/relay/adapter.py | 36 ++++++++++++++++--- .../relay/test_relay_slack_prompt_dm_root.py | 31 ++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index c7eaa1a981c63..4651ee1c34eeb 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -311,6 +311,24 @@ class RelayAdapter(BasePlatformAdapter): except Exception: # noqa: BLE001 - config shape is operator-owned return True + def _flat_dm_status_enabled(self) -> bool: + """Liveliness in flat-DM mode: anchor the STATUS (not the reply) to the + triggering message's ts. + + ``assistant.threads.setStatus`` on a message ts renders "… thinking" in + that message's thread-footer space and vanishes on clear — no message + artifact. Native suppresses this in flat mode because ITS response + routing could inherit the activated thread; the relay lane's sends are + explicitly flat in flat mode (QA-6/7 anchor strip), so the status + anchor cannot leak into reply placement here. Default ON — flat DMs + get a live status billboard while replies still post at the DM root. + Opt out: platforms.relay.extra.slack.flat_dm_status: false. + """ + try: + return bool(self._relay_slack_extra().get("flat_dm_status", True)) + except Exception: # noqa: BLE001 - config shape is operator-owned + return True + def _stamp_slack_session_thread(self, event) -> None: """Native session-keying parity for fronted Slack (QA-3). @@ -1023,9 +1041,17 @@ class RelayAdapter(BasePlatformAdapter): and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - reply_in_thread = self._effective_reply_in_thread() anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) - if reply_in_thread and anchor: + # Thread mode: status targets the per-message thread (QA-1). + # Flat mode: the status can STILL anchor to the triggering ts — + # setStatus renders in the footer space and clears without a + # message artifact, and flat sends strip their anchors (QA-6/7) + # so reply placement cannot inherit it. Gated separately + # (flat_dm_status, default on) for a clean opt-out. + if anchor and ( + self._effective_reply_in_thread() + or self._flat_dm_status_enabled() + ): md["thread_id"] = anchor # Rich status parity (QA-1): run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). @@ -1084,9 +1110,11 @@ class RelayAdapter(BasePlatformAdapter): not (md.get("thread_id") or md.get("thread_ts")) and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - reply_in_thread = self._effective_reply_in_thread() anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) - if reply_in_thread and anchor: + if anchor and ( + self._effective_reply_in_thread() + or self._flat_dm_status_enabled() + ): md["thread_id"] = anchor try: await self._transport.send_outbound( diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index 098b1fcaddebe..d1c062075b371 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -277,15 +277,42 @@ async def test_typing_synthesizes_thread_anchor_in_thread_mode(): @pytest.mark.asyncio -async def test_typing_keeps_no_anchor_in_flat_mode(): - """Flat mode: no synthetic thread for the status either (#18859).""" +async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default(): + """Flat-DM liveliness: the STATUS still anchors to the triggering ts + (renders in the footer space, no message artifact) while replies stay + flat — QA-6/7 strip send anchors, so placement cannot inherit this.""" adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") adapter.config.extra = {"reply_in_thread": False} await adapter.send_typing("D1", metadata=None) typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042" + + +@pytest.mark.asyncio +async def test_typing_flat_mode_opt_out_drops_anchor(): + """flat_dm_status: false restores the fully-anchorless flat posture.""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + adapter.config.extra = { + "slack": {"reply_in_thread": False, "flat_dm_status": False} + } + await adapter.send_typing("D1", metadata=None) + typing = [f for f in stub.sent if f["op"] == "typing"] assert typing and "thread_id" not in typing[-1]["metadata"] +@pytest.mark.asyncio +async def test_flat_mode_sends_stay_flat_with_status_anchor_active(): + """The liveliness anchor must NOT leak into reply placement: sends in + flat mode still strip the synthetic anchor (QA-6/7 contract).""" + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + adapter.config.extra = {"reply_in_thread": False} + await adapter.send_typing("D1", metadata=None) + await adapter.send("D1", "the answer", reply_to="1700.0042") + frame = [f for f in stub.sent if f["op"] == "send"][-1] + assert frame["reply_to"] is None + assert "thread_id" not in (frame["metadata"] or {}) + + @pytest.mark.asyncio async def test_typing_honours_real_thread_anchor(): """Metadata that already names a thread wins over the synthetic cache.""" From 85a75f3155c19de8ddeca9804567b2e128f5e1a3 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 00:05:41 +0000 Subject: [PATCH 23/36] =?UTF-8?q?refactor(relay):=20drop=20the=20flat=5Fdm?= =?UTF-8?q?=5Fstatus=20knob=20=E2=80=94=20liveliness=20is=20unconditional;?= =?UTF-8?q?=20add=20relay=20docs=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flat_dm_status was speculative config (rubric violation): no user wants 'make my agent look dead', and the only real consumer of status suppression was native's placement-contamination guard — which the relay lane handles structurally (QA-6/7 send-side anchor strip, leak-guard test), not via preference. Status now anchors whenever an inbound ts exists, in both modes. Docs: new website/docs/user-guide/messaging/relay.md — enterprise-only relay lane page documenting the platforms.relay.extra. subset shape (nested wins, flat fallback), the Slack reply_in_thread control, and always-on liveliness. Kept out of the native slack.md on purpose: relay controls are not Slack config. --- gateway/relay/adapter.py | 37 +++---------- .../relay/test_relay_slack_prompt_dm_root.py | 19 ++++--- website/docs/user-guide/messaging/relay.md | 55 +++++++++++++++++++ 3 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 website/docs/user-guide/messaging/relay.md diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 4651ee1c34eeb..1654e98807196 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -311,24 +311,6 @@ class RelayAdapter(BasePlatformAdapter): except Exception: # noqa: BLE001 - config shape is operator-owned return True - def _flat_dm_status_enabled(self) -> bool: - """Liveliness in flat-DM mode: anchor the STATUS (not the reply) to the - triggering message's ts. - - ``assistant.threads.setStatus`` on a message ts renders "… thinking" in - that message's thread-footer space and vanishes on clear — no message - artifact. Native suppresses this in flat mode because ITS response - routing could inherit the activated thread; the relay lane's sends are - explicitly flat in flat mode (QA-6/7 anchor strip), so the status - anchor cannot leak into reply placement here. Default ON — flat DMs - get a live status billboard while replies still post at the DM root. - Opt out: platforms.relay.extra.slack.flat_dm_status: false. - """ - try: - return bool(self._relay_slack_extra().get("flat_dm_status", True)) - except Exception: # noqa: BLE001 - config shape is operator-owned - return True - def _stamp_slack_session_thread(self, event) -> None: """Native session-keying parity for fronted Slack (QA-3). @@ -1041,17 +1023,15 @@ class RelayAdapter(BasePlatformAdapter): and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) # Thread mode: status targets the per-message thread (QA-1). - # Flat mode: the status can STILL anchor to the triggering ts — + # Flat mode: the status STILL anchors to the triggering ts — # setStatus renders in the footer space and clears without a # message artifact, and flat sends strip their anchors (QA-6/7) - # so reply placement cannot inherit it. Gated separately - # (flat_dm_status, default on) for a clean opt-out. - if anchor and ( - self._effective_reply_in_thread() - or self._flat_dm_status_enabled() - ): + # so reply placement cannot inherit it. Unconditional: liveliness + # is not a preference, it ships in whatever form the mode + # supports (no speculative opt-out knob). + anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) + if anchor: md["thread_id"] = anchor # Rich status parity (QA-1): run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). @@ -1111,10 +1091,7 @@ class RelayAdapter(BasePlatformAdapter): and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) - if anchor and ( - self._effective_reply_in_thread() - or self._flat_dm_status_enabled() - ): + if anchor: md["thread_id"] = anchor try: await self._transport.send_outbound( diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index d1c062075b371..7d2c630c07d18 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -289,15 +289,16 @@ async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default(): @pytest.mark.asyncio -async def test_typing_flat_mode_opt_out_drops_anchor(): - """flat_dm_status: false restores the fully-anchorless flat posture.""" - adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") - adapter.config.extra = { - "slack": {"reply_in_thread": False, "flat_dm_status": False} - } - await adapter.send_typing("D1", metadata=None) - typing = [f for f in stub.sent if f["op"] == "typing"] - assert typing and "thread_id" not in typing[-1]["metadata"] +async def test_typing_anchors_unconditionally_in_both_modes(): + """Liveliness is not a preference: the status anchors whenever an inbound + ts exists, regardless of reply_in_thread. Placement safety comes from the + QA-6/7 send-side anchor strip, not from suppressing the status.""" + for extra in ({}, {"slack": {"reply_in_thread": False}}): + adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") + adapter.config.extra = extra + await adapter.send_typing("D1", metadata=None) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042" @pytest.mark.asyncio diff --git a/website/docs/user-guide/messaging/relay.md b/website/docs/user-guide/messaging/relay.md new file mode 100644 index 0000000000000..22a98c1a149cd --- /dev/null +++ b/website/docs/user-guide/messaging/relay.md @@ -0,0 +1,55 @@ +# Relay (Team Gateway) — Enterprise + +> **Enterprise-only.** The relay lane applies when your Hermes gateway is +> fronted by a [Team Gateway connector](https://github.com/NousResearch/gateway-gateway) +> — the enterprise deployment model where the connector owns the platform +> credentials (e.g. one org Slack app) and Hermes speaks a relay protocol to +> it instead of connecting to the platform natively. Standalone/native +> installs can ignore this page; your platform's own page (e.g. +> [Slack](slack.md)) applies instead. + +## How configuration works on the relay lane + +The relay adapter is platform-neutral: the connector tells Hermes which +platform it fronts, and Hermes expresses per-turn decisions (threading, +status, placement) as frame metadata the connector executes mechanically. + +A small set of **platform behavior controls** exist for the fronted platform. +They live under `platforms.relay.extra.` — a supported *subset* of +that platform's native options — NOT under the native platform block: + +```yaml +platforms: + slack: # native adapter settings — ignored on the relay lane + ... + relay: + extra: + slack: # relay-lane subset for fronted Slack + reply_in_thread: true +``` + +Resolution order: the nested `extra.` object wins → a legacy flat +key on `extra` is honored as a fallback → the option's default. + +## Supported controls — Slack + +| Key | Default | Effect | +|---|---|---| +| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message opens its own thread carrying the entire turn (status, tool progress, approval cards, final reply), and each message runs as its own session so concurrent messages execute in parallel. `false`: flat rolling DM — everything posts at the DM root, one shared session, a second message steers the in-flight turn. | + +Semantics match the native Slack adapter's `reply_in_thread` +([Slack docs](slack.md)); the relay subset exists so relay-fronted behavior +is configured explicitly rather than inherited from a native block that the +relay lane does not read. + +In-progress "thinking…" statuses (with live per-tool phrases) are always on, +in whatever form the mode supports: the thread's replies footer in +thread-per-message mode, or anchored to the triggering message in flat mode. +They require only the `chat:write` bot scope — no Slack assistant surface. + +Changing a control takes effect on gateway restart — `hermes config set +platforms.relay.extra.slack.reply_in_thread false` and restart; no connector +deploy is involved. + +Other fronted platforms currently have no relay-lane controls; the set grows +as enterprise deployments need them (each addition is documented here). From daefa8c34ed9df8f94e1ccc7ddf1878af107a54f Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 00:12:00 +0000 Subject: [PATCH 24/36] docs(relay): move behavior-controls docs into the relay-connector contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the platforms.relay.extra. documentation from a new user-guide page into docs/relay-connector-contract.md (the existing canonical relay doc, already linked from gateway-internals) as §8. The relay lane is an enterprise-only component: it gets minor coverage in the developer-facing contract doc, not a prominent user-guide page, and no links to private components. --- docs/relay-connector-contract.md | 39 ++++++++++++++- website/docs/user-guide/messaging/relay.md | 55 ---------------------- 2 files changed, 38 insertions(+), 56 deletions(-) delete mode 100644 website/docs/user-guide/messaging/relay.md diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 3698b55432a66..e13d51459ddaa 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -711,7 +711,44 @@ per-gateway secret and the same host as `/relay/provision`. --- -## 8. Versioning policy +## 8. Gateway-side platform behavior controls (enterprise) + +Enterprise deployments configure fronted-platform behavior on the GATEWAY +side, under `platforms.relay.extra.` — a supported subset of that +platform's native options. The native platform block (e.g. `platforms.slack`) +is not read on the relay lane; the connector receives the *outcome* of these +controls as frame metadata (§4) and executes mechanically — it holds no +platform behavior policy of its own. + +```yaml +platforms: + relay: + extra: + slack: + reply_in_thread: true # default +``` + +Resolution: nested `extra.` object wins → legacy flat key on +`extra` honored as fallback → default. Source of truth: +`RelayAdapter._effective_reply_in_thread` (`gateway/relay/adapter.py`). + +Current controls (Slack): + +| Key | Default | Effect | +| --- | --- | --- | +| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that `metadata.thread_id`) and keys its own session, so concurrent messages run in parallel. `false`: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. | + +Typing/status frames always carry the triggering-ts anchor when one is known +(liveliness is unconditional, both modes): Slack's status line is +thread-scoped, and in flat mode the send-side anchor strip guarantees the +status anchor can never leak into reply placement. Semantics of the native +key: see `website/docs/user-guide/messaging/slack.md`. + +Changes take effect on gateway restart; no connector involvement. + +--- + +## 9. Versioning policy - `contract_version` is an int; bump **only** for additive changes during the experimental phase (new optional fields, new `op`s). diff --git a/website/docs/user-guide/messaging/relay.md b/website/docs/user-guide/messaging/relay.md deleted file mode 100644 index 22a98c1a149cd..0000000000000 --- a/website/docs/user-guide/messaging/relay.md +++ /dev/null @@ -1,55 +0,0 @@ -# Relay (Team Gateway) — Enterprise - -> **Enterprise-only.** The relay lane applies when your Hermes gateway is -> fronted by a [Team Gateway connector](https://github.com/NousResearch/gateway-gateway) -> — the enterprise deployment model where the connector owns the platform -> credentials (e.g. one org Slack app) and Hermes speaks a relay protocol to -> it instead of connecting to the platform natively. Standalone/native -> installs can ignore this page; your platform's own page (e.g. -> [Slack](slack.md)) applies instead. - -## How configuration works on the relay lane - -The relay adapter is platform-neutral: the connector tells Hermes which -platform it fronts, and Hermes expresses per-turn decisions (threading, -status, placement) as frame metadata the connector executes mechanically. - -A small set of **platform behavior controls** exist for the fronted platform. -They live under `platforms.relay.extra.` — a supported *subset* of -that platform's native options — NOT under the native platform block: - -```yaml -platforms: - slack: # native adapter settings — ignored on the relay lane - ... - relay: - extra: - slack: # relay-lane subset for fronted Slack - reply_in_thread: true -``` - -Resolution order: the nested `extra.` object wins → a legacy flat -key on `extra` is honored as a fallback → the option's default. - -## Supported controls — Slack - -| Key | Default | Effect | -|---|---|---| -| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message opens its own thread carrying the entire turn (status, tool progress, approval cards, final reply), and each message runs as its own session so concurrent messages execute in parallel. `false`: flat rolling DM — everything posts at the DM root, one shared session, a second message steers the in-flight turn. | - -Semantics match the native Slack adapter's `reply_in_thread` -([Slack docs](slack.md)); the relay subset exists so relay-fronted behavior -is configured explicitly rather than inherited from a native block that the -relay lane does not read. - -In-progress "thinking…" statuses (with live per-tool phrases) are always on, -in whatever form the mode supports: the thread's replies footer in -thread-per-message mode, or anchored to the triggering message in flat mode. -They require only the `chat:write` bot scope — no Slack assistant surface. - -Changing a control takes effect on gateway restart — `hermes config set -platforms.relay.extra.slack.reply_in_thread false` and restart; no connector -deploy is involved. - -Other fronted platforms currently have no relay-lane controls; the set grows -as enterprise deployments need them (each addition is documented here). From a09015d31e29cac5389fac8eb662b49c0645a8d1 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 11:42:40 +0000 Subject: [PATCH 25/36] Revert "fix(scripts): encode tool_search_livetest2 output as utf-8 (Windows footgun)" This reverts commit e286658377ed63f17582be59bdc081811ed63b3d. --- scripts/tool_search_livetest2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tool_search_livetest2.py b/scripts/tool_search_livetest2.py index 522b141685c50..b81f91f91afd0 100644 --- a/scripts/tool_search_livetest2.py +++ b/scripts/tool_search_livetest2.py @@ -187,7 +187,7 @@ def run_one(scenario: Dict[str, Any], mode: str, rep: int, out_dir: Path) -> Dic "final_response": base._redact_secrets(final_response)[:500], } out_path = out_dir / f"{scenario['id']}__{'enabled' if enabled else 'disabled'}__rep{rep}.json" - out_path.write_text(json.dumps(rec, indent=1), encoding="utf-8") + out_path.write_text(json.dumps(rec, indent=1)) shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True) return rec From 09c4a1d34917d319d2f3f55a6ff3dd023a29a39f Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 11:33:01 +0000 Subject: [PATCH 26/36] refactor(relay): remove dead _strip_synthetic_dm_thread; pin the run.py anchor-suppression boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (2026-07-28): every path through _strip_synthetic_dm_thread returned metadata unmodified — the actual strip was removed when prompts switched to trusting the run.py thread stamp, leaving a 50-line no-op and four tests that passed against it (verified by reviewer's negative control). - delete the function + its _send_prompt call site (verbatim pass-through with a pointer comment to the single mode authority) - rewrite the three pass-through tests as end-to-end placement contracts (forward run.py's stamp untouched) - NEW boundary tests pinning run.py._resolve_progress_thread_id itself: flat mode suppresses the synthetic self-anchor / preserves real threads; thread mode keeps the first-turn self-anchor. This is the cross-module coupling the review flagged as unpinned — if the upstream suppression regresses, these fail instead of prompts silently threading. --- gateway/relay/adapter.py | 61 ++--------------- .../relay/test_relay_slack_prompt_dm_root.py | 65 ++++++++++++++++--- 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 1654e98807196..646f720a9bd1c 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1448,61 +1448,6 @@ class RelayAdapter(BasePlatformAdapter): return None return state - def _strip_synthetic_dm_thread( - self, chat_id: str, metadata: Optional[Dict[str, Any]] - ) -> Optional[Dict[str, Any]]: - """Drop the synthetic DM thread anchor from an interactive prompt's metadata. - - A clarify/approval/confirm prompt is emitted mid-turn in reply to the - triggering inbound event, so ``metadata`` carries that event's thread - context — run.py's ``_thread_metadata_for_source`` stamps - ``metadata["thread_id"]`` (and, for Slack, ``metadata["message_id"]`` = - the triggering message ts). For a Slack DM with no REAL thread, that - ``thread_id`` is the message's own synthetic self-anchor (a session-keying - fallback), and forwarding it makes the connector's slackRestSender thread - the prompt card UNDER the user's message instead of posting it flat at the - DM root — the reported bug ("approval block was put in a thread"). - - Native Slack Hermes already suppresses this synthetic DM thread anchor - (``SlackAdapter._resolve_thread_ts`` returns ``None`` for a top-level / DM - message). We reproduce it here with the same discipline used on the - streaming path (``_resolve_reply_to_for_send``): - - Slack DM + thread_id is the synthetic self-anchor ⇒ strip thread_id. - - A REAL thread (``thread_id`` distinct from the triggering message ts) is - left untouched so a prompt raised inside a thread stays in that thread; - non-DM / non-Slack chats are never matched. Only the threading keys are - removed — tenant scope (``scope_id`` / ``slack_team_id``) and everything - else survive so egress routing is unaffected. - """ - if not metadata: - return metadata - if self._platform_by_chat.get(str(chat_id)) != Platform.SLACK.value: - return metadata - if self._chat_type_by_chat.get(str(chat_id)) != "dm": - return metadata - thread_id = metadata.get("thread_id") - if not thread_id: - return metadata - # Trust the run.py stamp (QA-5). The threading MODE is decided in ONE - # place — run.py's _resolve_progress_thread_id, which reads - # platforms.slack.extra.reply_in_thread: - # * flat mode (reply_in_thread=false): the synthetic self-anchor is - # suppressed THERE, so prompt metadata arrives with NO thread_id and - # this helper is a no-op — the card posts flat at the DM root; - # * thread-per-message mode (default): metadata.thread_id is stamped - # for the whole turn, and on the FIRST turn it legitimately equals - # the triggering message's ts (the synthetic root IS the thread). - # The previous unconditional thread_id == message_id strip re-derived - # the mode here and got it wrong for thread-per-message: the approval - # card (and its resolved-state swap) was exiled to the DM root while - # progress bubbles honoured the thread (2026-07-27 mixed-placement - # screenshot). Mirror native SlackAdapter._resolve_thread_ts, which - # only performs the self-anchor strip when reply_in_thread=false — a - # state this lane never sees with an anchor present, per the above. - return metadata - async def _send_prompt( self, chat_id: str, @@ -1533,7 +1478,11 @@ class RelayAdapter(BasePlatformAdapter): # of posting it flat at the DM root (the reported bug). Native Slack # Hermes suppresses this synthetic DM thread anchor; drop it here for the # same Slack-DM-with-no-real-thread case, matching _resolve_reply_to_for_send. - prompt_metadata = self._strip_synthetic_dm_thread(chat_id, metadata) + # Prompt metadata is forwarded VERBATIM. The threading mode is decided + # in exactly one place — run.py's _resolve_progress_thread_id (flat mode + # suppresses the synthetic self-anchor there; thread mode stamps the + # turn's thread). Boundary pinned by test_run_py_suppresses_self_anchor*. + prompt_metadata = metadata action: Dict[str, Any] = { "op": "prompt", "chat_id": chat_id, diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index 7d2c630c07d18..eabc3b2d412bb 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -110,14 +110,16 @@ async def test_exec_approval_flat_mode_posts_at_dm_root(): # --------------------------------------------------------------------------- -# Thread-per-message mode: the first-turn self-anchor (thread_id == message_id) -# IS the thread root — the prompt must stay in the thread (QA-5 regression). +# Thread-per-message mode, end-to-end placement contract: run.py stamps the +# turn's thread (first turn: the triggering message's own ts) and the adapter +# forwards prompt metadata UNTOUCHED — no re-derivation, no strip. Mixed +# placement (progress threaded, card at root) was the 2026-07-27 regression. # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_exec_approval_first_turn_self_anchor_stays_in_thread(): - """Thread-per-message first turn: run.py stamps thread_id = the triggering - message's own ts. The approval card must post INTO that thread — stripping - it exiled the card to the home channel (2026-07-27 report).""" +async def test_exec_approval_forwards_run_py_thread_stamp_untouched(): + """The adapter must forward run.py's thread stamp verbatim: the approval + card posts INTO the stamped thread. Any adapter-side re-derivation or + strip exiled the card to the home channel (2026-07-27 report).""" adapter, stub = _wire("D1", "dm", scope_id="T1") md = { "thread_id": "1700000000.000100", @@ -137,7 +139,7 @@ async def test_exec_approval_first_turn_self_anchor_stays_in_thread(): @pytest.mark.asyncio -async def test_clarify_first_turn_self_anchor_stays_in_thread(): +async def test_clarify_forwards_run_py_thread_stamp_untouched(): adapter, stub = _wire("D1", "dm", scope_id="T1") md = { "thread_id": "1700000000.000200", @@ -155,8 +157,8 @@ async def test_clarify_first_turn_self_anchor_stays_in_thread(): @pytest.mark.asyncio -async def test_slash_confirm_first_turn_self_anchor_stays_in_thread(): - """The stamp-trusting rule covers every prompt surface (single +async def test_slash_confirm_forwards_run_py_thread_stamp_untouched(): + """The forward-untouched rule covers every prompt surface (single _send_prompt choke point).""" adapter, stub = _wire("D1", "dm") md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"} @@ -403,3 +405,48 @@ def test_nested_relay_slack_config_subset_wins(): # Default: thread-per-message. adapter.config.extra = {} assert adapter._effective_reply_in_thread() is True + + +# --------------------------------------------------------------------------- +# Cross-module boundary pin (review 2026-07-28): the adapter deliberately has +# NO prompt-side strip — flat-mode placement depends entirely on run.py's +# _resolve_progress_thread_id suppressing the synthetic self-anchor upstream. +# If that suppression regresses, prompt cards silently thread again. These +# tests pin the boundary in BOTH modes so the coupling is load-bearing. +# --------------------------------------------------------------------------- +def test_run_py_suppresses_self_anchor_in_flat_mode(): + from gateway.run import _resolve_progress_thread_id + + # Flat mode + synthetic self-anchor (thread_id == own message id) => None: + # prompt/progress metadata arrives at the adapter with NO thread anchor. + assert ( + _resolve_progress_thread_id( + "slack", "1700.001", "1700.001", reply_in_thread=False + ) + is None + ) + # Flat mode + REAL thread (ids differ) => the real thread survives. + assert ( + _resolve_progress_thread_id( + "slack", "1699.000", "1700.001", reply_in_thread=False + ) + == "1699.000" + ) + + +def test_run_py_keeps_self_anchor_in_thread_mode(): + from gateway.run import _resolve_progress_thread_id + + # Thread-per-message mode: the first-turn self-anchor IS the thread root + # and must flow through to the adapter unchanged. + assert ( + _resolve_progress_thread_id( + "slack", "1700.001", "1700.001", reply_in_thread=True + ) + == "1700.001" + ) + # No source thread at all: Slack synthesizes the root from the message id. + assert ( + _resolve_progress_thread_id("slack", None, "1700.001", reply_in_thread=True) + == "1700.001" + ) From 3e628edeb928581d83cd6c34a3cc3b3911b046d9 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 11:33:25 +0000 Subject: [PATCH 27/36] docs(relay): replace internal QA-N tracker markers with behavior descriptions Review finding: QA-1/3/5/6/7 are internal campaign tracker ids meaning nothing to future readers of this file. Comments now describe the behavior (status thread targeting, metadata-only threading, session-keying parity) instead of citing the tracker. Comment-only change. --- gateway/relay/adapter.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 646f720a9bd1c..8e127ac1f072a 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -83,7 +83,7 @@ class RelayAdapter(BasePlatformAdapter): # disambiguation, and it needs the chat_type to know a chat is a DM. self._chat_type_by_chat: Dict[str, str] = {} # chat_id -> last triggering message ts (Slack). The typing/status - # lane's synthetic thread anchor in thread-per-message mode (QA-1); + # lane's synthetic thread anchor in thread-per-message mode; # see _capture_scope and send_typing. self._last_inbound_ts_by_chat: Dict[str, str] = {} # chat_id -> the UNDERLYING platform (e.g. "discord", "telegram") this @@ -141,7 +141,7 @@ class RelayAdapter(BasePlatformAdapter): def supports_status_text(self) -> bool: # type: ignore[override] """Whether the fronted platform renders a TEXT status line. - Native parity (QA-1 rich status): Slack's typing surface is the + Native parity (rich status text): Slack's typing surface is the assistant status line ("Finding answers…" next to the bot name), a text-rendering indicator. When the relay fronts Slack, advertise it so run.py's live-status lane feeds per-tool phrases via @@ -312,7 +312,7 @@ class RelayAdapter(BasePlatformAdapter): return True def _stamp_slack_session_thread(self, event) -> None: - """Native session-keying parity for fronted Slack (QA-3). + """Native session-keying parity for fronted Slack DMs. Native SlackAdapter's inbound handler stamps ``thread_ts = event.thread_ts or ts`` — every TOP-LEVEL message carries its own ts @@ -448,7 +448,7 @@ class RelayAdapter(BasePlatformAdapter): chat_type = getattr(src, "chat_type", None) if chat_type: self._chat_type_by_chat[str(chat)] = str(chat_type) - # Triggering message ts (QA-1): the typing/status lane's metadata + # Triggering message ts: the typing/status lane's metadata # (base.py _thread_metadata_for_source) carries NO thread anchor # for a top-level DM, but in thread-per-message mode the status # must target the per-message thread (its root = this ts). Cache @@ -850,7 +850,7 @@ class RelayAdapter(BasePlatformAdapter): ) if effective_reply_to is None and reply_to is not None: send_metadata.pop("reply_to_message_id", None) - # QA-7: the connector's Slack sender THREADS ON METADATA ONLY — + # The connector's Slack sender THREADS ON METADATA ONLY — # threadTs() reads metadata.thread_id/thread_ts and never looks at # the frame's reply_to. A send whose only threading signal is # reply_to (base.py's final-reply and fallback lanes build metadata @@ -939,7 +939,7 @@ class RelayAdapter(BasePlatformAdapter): # threading signal (run.py's synthetic root feeds just the # progress/status lane). Dropping it unconditionally exiled the final # message to the DM root while progress stayed threaded (2026-07-27 - # report, sibling of the QA-5 prompt bug). Native SlackAdapter only + # report, same class as the prompt-placement bug). Native SlackAdapter only # suppresses the anchor when reply_in_thread=false; mirror that. reply_in_thread = self._effective_reply_in_thread() if reply_in_thread: @@ -1005,7 +1005,7 @@ class RelayAdapter(BasePlatformAdapter): """ if self._transport is None: return - # Thread anchor for the status surface (QA-1). Slack's status line + # Thread anchor for the status surface. Slack's status line # ("is thinking…" in the thread's replies footer — works with plain # chat:write, confirmed on native no-assistant bots) is THREAD-only: # the connector's typing case no-ops without a thread_ts. But the @@ -1023,17 +1023,17 @@ class RelayAdapter(BasePlatformAdapter): and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): - # Thread mode: status targets the per-message thread (QA-1). + # Thread mode: status targets the per-message thread. # Flat mode: the status STILL anchors to the triggering ts — # setStatus renders in the footer space and clears without a - # message artifact, and flat sends strip their anchors (QA-6/7) + # message artifact, and flat sends strip their anchors # so reply placement cannot inherit it. Unconditional: liveliness # is not a preference, it ships in whatever form the mode # supports (no speculative opt-out knob). anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) if anchor: md["thread_id"] = anchor - # Rich status parity (QA-1): run.py's live-status lane stashes the + # Rich status parity: run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). # Carry it as the typing frame's content so the connector's Slack # sender renders it on assistant.threads.setStatus — the same phrase @@ -1081,7 +1081,7 @@ class RelayAdapter(BasePlatformAdapter): platform = self._platform_by_chat.get(str(chat_id)) if platform != Platform.SLACK.value: return - # Clear must target the SAME thread the heartbeat set (QA-1): apply + # Clear must target the SAME thread the heartbeat set: apply # the identical synthetic-anchor rule as send_typing, or the clear # frame no-ops threadless and the status line sticks until Slack's # own timeout. From 277fc97a0a16e57d04814e41fb9d32ae83332743 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Tue, 28 Jul 2026 11:34:59 +0000 Subject: [PATCH 28/36] =?UTF-8?q?feat(relay):=20dm=5Ftop=5Flevel=5Fthreads?= =?UTF-8?q?=5Fas=5Fsessions=20escape=20hatch=20=E2=80=94=20native=20sessio?= =?UTF-8?q?n-keying=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: native gates per-message DM sessions behind platforms.slack.extra.dm_top_level_threads_as_sessions; the relay lane coupled session keying to reply_in_thread alone, so 'threaded replies + one rolling session' was expressible on native but not here. Adds the same knob to the relay subset (platforms.relay.extra.slack. dm_top_level_threads_as_sessions, default true = per-message sessions, unchanged behavior). false keeps thread-per-message reply placement but skips the session stamp — one rolling DM session, legacy steer posture. TDD: opt-out + default-unchanged tests written first. --- docs/relay-connector-contract.md | 3 +- gateway/relay/adapter.py | 22 ++++++++++++ .../relay/test_relay_slack_prompt_dm_root.py | 34 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index e13d51459ddaa..91ae62b69e0ee 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -736,7 +736,8 @@ Current controls (Slack): | Key | Default | Effect | | --- | --- | --- | -| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that `metadata.thread_id`) and keys its own session, so concurrent messages run in parallel. `false`: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. | +| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that `metadata.thread_id`). `false`: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. | +| `dm_top_level_threads_as_sessions` | `true` | Native-parity escape hatch (mirrors `platforms.slack.extra.dm_top_level_threads_as_sessions`). `true`: in thread-per-message mode each top-level DM message keys its own session, so concurrent messages run in parallel. `false`: threaded reply placement is kept but the session stamp is skipped — one rolling DM session (legacy steer/queue posture). No effect in flat mode, which always keeps the single rolling session. | Typing/status frames always carry the triggering-ts anchor when one is known (liveliness is unconditional, both modes): Slack's status line is diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 8e127ac1f072a..71eb326bc72c8 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -311,6 +311,26 @@ class RelayAdapter(BasePlatformAdapter): except Exception: # noqa: BLE001 - config shape is operator-owned return True + def _dm_top_level_threads_as_sessions(self) -> bool: + """Native-parity escape hatch: per-message DM sessions on/off. + + Mirrors native SlackAdapter._dm_top_level_threads_as_sessions + (platforms.slack.extra.dm_top_level_threads_as_sessions). Default + True: in thread-per-message mode each top-level DM message keys its + own session (parallel turns). Set + platforms.relay.extra.slack.dm_top_level_threads_as_sessions: false + to keep threaded reply PLACEMENT but ONE rolling DM session — the + legacy steer/queue posture, decoupled from reply_in_thread. + """ + try: + return bool( + self._relay_slack_extra().get( + "dm_top_level_threads_as_sessions", True + ) + ) + except Exception: # noqa: BLE001 - config shape is operator-owned + return True + def _stamp_slack_session_thread(self, event) -> None: """Native session-keying parity for fronted Slack DMs. @@ -345,6 +365,8 @@ class RelayAdapter(BasePlatformAdapter): return if not self._effective_reply_in_thread(): return + if not self._dm_top_level_threads_as_sessions(): + return # opt-out: threaded replies, one rolling session src.thread_id = str(message_id) except Exception: # noqa: BLE001 - session stamping must never break inbound logger.debug("slack session-thread stamp failed", exc_info=True) diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index eabc3b2d412bb..56869f4c6fa35 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -450,3 +450,37 @@ def test_run_py_keeps_self_anchor_in_thread_mode(): _resolve_progress_thread_id("slack", None, "1700.001", reply_in_thread=True) == "1700.001" ) + + +# --------------------------------------------------------------------------- +# Native parity escape hatch: platforms.relay.extra.slack. +# dm_top_level_threads_as_sessions=false keeps threaded replies but ONE +# rolling DM session (mirrors native SlackAdapter._dm_top_level_threads_as_sessions). +# Without the knob, reply_in_thread alone couples placement AND session +# keying — a posture native operators can express and relay ones could not. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_session_stamp_opt_out_keeps_rolling_dm_session(): + adapter, stub = _wire("D1", "dm") + adapter.config.extra = { + "slack": { + "reply_in_thread": True, + "dm_top_level_threads_as_sessions": False, + } + } + event = _inbound_event("D1", message_id="1700.0001", thread_id=None) + adapter._stamp_slack_session_thread(event) + assert getattr(event.source, "thread_id", None) is None, ( + "opt-out: top-level DM must NOT be stamped — one rolling session" + ) + + +@pytest.mark.asyncio +async def test_session_stamp_default_remains_per_message(): + adapter, stub = _wire("D1", "dm") + adapter.config.extra = {"slack": {"reply_in_thread": True}} + event = _inbound_event("D1", message_id="1700.0002", thread_id=None) + adapter._stamp_slack_session_thread(event) + assert getattr(event.source, "thread_id", None) == "1700.0002", ( + "default (native parity): per-message sessions stay on" + ) From 4f990ec09ea93fe6f13ea7367103ee5f3434bb55 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 07:47:06 +1000 Subject: [PATCH 29/36] refactor(sync): put every Skill Sync verb under `hermes sync`; drop HSP naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encapsulates the feature behind one command for launch, and adopts the official product name. One command: - `propose` moves from `hermes skills propose` to `hermes sync propose`, so the whole feature is one command to learn and one to document. Its handler moves from cmd_skills to cmd_sync accordingly. - The `hermes sync` parser now documents both halves plainly: personal sync across your devices, and sharing with your organisation. Added an examples epilog; rewrote the verb help in user language ("Include a skill in your sync" rather than "Opt a skill into sync"). - Every user-facing string that pointed at `hermes skills propose` now points at `hermes sync propose` (8 sites, including the agent-visible guidance returned by skill_manage and the org provenance header). This also clears the way for #39343, which adds its own top-level `sync` for git-repo profile backup — that feature nests under `skills`, this one owns `sync`. Naming: - HSP / "Hermes Sync Protocol" is gone from prose, docstrings, and comments. The feature is "Skill Sync". - Public identifiers renamed: HSPClient -> SyncClient, HSPError -> SyncError, HSPConflict -> SyncConflict, hsp_address -> wire_address, HSP_VERSION -> WIRE_VERSION. - The WIRE names are deliberately NOT renamed: the `hsp_version` capability field and the `x-hsp-object-type` response header are set by the deployed gateway-gateway sync plane (verified in src/sync/syncRouter.ts), so renaming them client-side would break sync against a live server. A comment at the version constant records why they differ from the product name. - The version-mismatch error is now actionable ("this server speaks sync version X, but this Hermes speaks Y — update Hermes to sync with it") instead of leaking the protocol acronym. Also fixes a wiring gap found on the way: the gateway housekeeping tick pulled personal skills but never org skills — the same defect already fixed for the CLI. Org pull now runs there too, gated on real org membership. Tests: the jargon guard now also fails on a bare "HSP". The two tests that asserted the old cross-command structure are replaced by three asserting the new one (propose IS under sync, propose is NOT under skills, sync usage lists it). 2294 passed / 0 failed across all 51 suites that import the changed modules, via scripts/run_tests.sh. Verified by running the real CLI: `hermes sync --help` lists all eight verbs, `hermes skills --help` no longer mentions propose, `hermes sync propose --help` parses, and `hermes sync status` still reports live org state. --- gateway/run.py | 14 ++- hermes_cli/main.py | 77 ++++++------ hermes_cli/subcommands/skills.py | 22 ---- hermes_cli/subcommands/sync.py | 86 ++++++++------ tests/agent/test_org_skill_namespace.py | 56 ++++----- tests/tools/test_skills_sync_client.py | 46 ++++---- tools/skill_manager_tool.py | 14 +-- tools/skill_usage.py | 4 +- tools/skills_sync_client.py | 149 +++++++++++++----------- tools/skills_tool.py | 2 +- 10 files changed, 240 insertions(+), 230 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index e5ba9a546e85c..4f610b3bf8905 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -22830,15 +22830,23 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop except Exception as e: logger.debug("Curator tick error: %s", e) - # HSP skill sync — best-effort periodic pull on the same cadence. - # Inert unless the DEV-PHASE gate is open (tool_gateway_admin) and - # a sync base URL is configured; never raises. + # Skill Sync — best-effort periodic pull on the same cadence. + # Inert unless the access gate is open and a sync base URL is + # configured; never raises. try: from tools.skills_sync_client import maybe_pull_skills maybe_pull_skills() except Exception as e: logger.debug("Sync pull tick error: %s", e) + # Org-shared skills. Gated on real org membership (the token must + # carry an org role), so a solo account never reaches the network. + try: + from tools.skills_sync_client import maybe_pull_org_skills + maybe_pull_org_skills() + except Exception as e: + logger.debug("Org sync pull tick error: %s", e) + stop_event.wait(timeout=interval) logger.info("Gateway housekeeping stopped") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ac374175b6bc6..43de6abe69996 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4440,26 +4440,27 @@ def cmd_cron(args): def cmd_sync(args): - """HSP/1 personal skill sync management (status/pull/push/now/enable/disable).""" + """Skill Sync — personal sync across devices, plus sharing with your org.""" import json as _json sub = getattr(args, "sync_command", None) if sub in {None, ""}: print( - "usage: hermes sync \n" + "usage: hermes sync " + "\n" "\n" - " status Show sync gate, opt-in, and head state\n" - " pull Pull the owner's HEAD, materialize opted-in skills\n" - " push Push opted-in skills to the owner's HEAD\n" + "Your skills, across your devices:\n" + " status Show what is synced, and from where\n" + " pull Pull your synced skills\n" + " push Push your opted-in skills\n" " now Reconcile now: pull then push\n" - " enable Opt a skill into sync\n" - " disable Opt a skill out of sync\n" - " device [--name N] Show or set this device's sync label\n" + " enable Include a skill in your sync\n" + " disable Exclude a skill from your sync\n" + " device [--name N] Show or set this device's label\n" "\n" - "These cover your PERSONAL skills, across your own devices.\n" - "To share a skill with your organisation instead:\n" - " hermes skills propose ", + "Shared with your team:\n" + " propose Share a skill with your organisation", file=sys.stderr, ) return 1 @@ -4485,6 +4486,28 @@ def cmd_sync(args): print(ssc.stable_device_id()) return 0 + if sub == "propose": + from tools import skills_sync_client as ssc + + name = args.name + try: + result = ssc.propose_skill(name, message=args.message) + except ssc.SyncInertError as e: + print(f"cannot share this skill: {e}", file=sys.stderr) + return 1 + except ssc.SyncError as e: + print(f"could not share '{name}': {e}", file=sys.stderr) + return 1 + if result.get("proposal_pending"): + print( + f"Shared '{name}' with your organisation — an admin needs to " + f"approve it (proposal #{result.get('proposal_id')}). It is " + f"not live for the team until then." + ) + else: + print(f"Added '{name}' to your organisation's shared skills.") + return 0 + if sub in {"enable", "disable"}: from tools.skill_usage import set_sync, is_curation_eligible @@ -4519,7 +4542,7 @@ def cmd_sync(args): print( f" {len(modified)} with local edits not yet shared: " f"{', '.join(modified)}\n" - f" Share them back with `hermes skills propose `. " + f" Share them back with `hermes sync propose `. " f"Org updates will not overwrite them.", file=sys.stderr, ) @@ -4603,7 +4626,7 @@ def cmd_sync(args): else: print(f"Unknown sync subcommand: {sub}", file=sys.stderr) return 1 - except ssc.HSPError as e: + except ssc.SyncError as e: print(f"sync failed: {e}", file=sys.stderr) return 1 @@ -13942,34 +13965,6 @@ def cmd_skills(args): from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) - elif getattr(args, "skills_action", None) == "propose": - # M2 org-shared skills (hsp-1-contract.md §11.5): propose a local - # skill to the org canonical set. 202 => pending review (NEVER shown - # as live); direct merge for admins. Personal orgs have no org - # workflow — say so plainly instead of a raw 403. - from tools import skills_sync_client as ssc - - name = args.name - try: - result = ssc.propose_skill(name, message=args.message) - except ssc.SyncInertError as e: - print(f"org sync unavailable: {e}", file=sys.stderr) - return 1 - except ssc.HSPError as e: - print(f"propose failed: {e}", file=sys.stderr) - return 1 - if result.get("proposal_pending"): - print( - f"proposed '{name}' — pending admin review " - f"(proposal #{result.get('proposal_id')}). Not live for the " - f"org until approved." - ) - else: - print( - f"merged '{name}' into the org set " - f"(head {str(result.get('head', ''))[:19]}…)." - ) - return 0 else: from hermes_cli.skills_hub import skills_command diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index 291697f5aff05..4eb68a01b1c7a 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -313,26 +313,4 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: help="Interactive skill configuration — enable/disable individual skills", ) - # M2 org-shared skills (hsp-1-contract.md §11.5/§11.11): propose a local - # skill's content to the org canonical set. MEMBER → 202 proposal - # (pending admin review); ADMIN/OWNER → direct merge. Only meaningful for - # multi-member orgs — personal orgs have no org workflow (the command - # reports that instead of failing opaquely). - skills_propose = skills_subparsers.add_parser( - "propose", - help="Propose a skill to your organisation's shared skill set", - description=( - "Snapshot the local skill and submit it to the org canonical set. " - "An org admin's push merges directly; a member's push becomes a " - "proposal reviewed in the org console. Personal orgs keep simple " - "personal sync and have no proposal workflow." - ), - ) - skills_propose.add_argument("name", help="Skill name to propose") - skills_propose.add_argument( - "-m", - "--message", - default=None, - help="Optional proposal message (defaults to 'propose ')", - ) skills_parser.set_defaults(func=cmd_skills) diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index bdc771d282da2..48eb133407235 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -1,21 +1,21 @@ -"""``hermes sync`` subcommand parser (personal skill sync). +"""``hermes sync`` subcommand parser — Skill Sync. Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape (``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance). -Commands: - hermes sync status -- show gate/opt-in/head state - hermes sync pull -- pull the owner's HEAD, materialize opted-in skills - hermes sync push -- push opted-in skills to the owner's HEAD - hermes sync now -- pull then push (full reconcile) - hermes sync enable -- opt a skill into sync - hermes sync disable -- opt a skill out of sync - hermes sync device [--name] -- show or set this device's sync label +Skill Sync covers two surfaces, both under this one command for launch: -This surface is PERSONAL sync only: it moves your own skills between your own -devices via ``refs/user//HEAD``. Sharing a skill with an organisation -is a different operation with a different destination and an approval step — -see ``hermes skills propose``. + Personal — your own skills, across your own devices: + hermes sync status show gate/opt-in/head state + hermes sync pull pull and materialize opted-in skills + hermes sync push push opted-in skills + hermes sync now reconcile: pull then push + hermes sync enable opt a skill into sync + hermes sync disable opt a skill out of sync + hermes sync device [--name] show or set this device's label + + Organisation — skills shared with your team: + hermes sync propose share a skill with your organisation Sync is INERT unless the resolved Nous token carries the access-gate claim AND a sync base URL is configured. The commands report that state rather than @@ -24,49 +24,48 @@ failing opaquely. from __future__ import annotations +import argparse from typing import Callable def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: """Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``.""" - import argparse - sync_parser = subparsers.add_parser( "sync", - help="Personal skill sync across your devices", + help="Skill Sync — sync your skills across devices and with your team", description=( - "Sync agent-created and user-authored skills across your own " - "devices." + "Skill Sync keeps your skills with you. Personal sync moves your " + "own skills between your devices; if you belong to an " + "organisation, you also get its shared skills and can propose " + "your own back to the team." ), epilog=( - "Sharing with your team:\n" - " These commands cover your PERSONAL skills only. To share a " - "skill with your\n" - " organisation, use `hermes skills propose ` instead — it " - "submits the\n" - " skill to your org's shared set (an admin approves it unless " - "you are one).\n" - " Approved org skills arrive automatically and are read-only " - "locally.\n" + "Examples:\n" + " hermes sync status what is synced, and from where\n" + " hermes sync enable my-skill include a skill in your sync\n" + " hermes sync now pull, then push\n" + " hermes sync propose my-skill share a skill with your team\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) sync_sub = sync_parser.add_subparsers(dest="sync_command") - sync_sub.add_parser("status", help="Show sync gate, opt-in, and head state") - sync_sub.add_parser("pull", help="Pull the owner's HEAD and materialize opted-in skills") - sync_sub.add_parser("push", help="Push opted-in skills to the owner's HEAD") + sync_sub.add_parser("status", help="Show what is synced, and from where") + sync_sub.add_parser( + "pull", help="Pull your synced skills (and your organisation's)" + ) + sync_sub.add_parser("push", help="Push your opted-in skills") sync_sub.add_parser("now", help="Reconcile now: pull then push") - enable = sync_sub.add_parser("enable", help="Opt a skill into sync") + enable = sync_sub.add_parser("enable", help="Include a skill in your sync") enable.add_argument("skill", help="Skill name (frontmatter name / directory name)") - disable = sync_sub.add_parser("disable", help="Opt a skill out of sync") + disable = sync_sub.add_parser("disable", help="Exclude a skill from your sync") disable.add_argument("skill", help="Skill name (frontmatter name / directory name)") device = sync_sub.add_parser( "device", - help="Show or set this device's sync label (shown in the sync console)", + help="Show or set this device's label (shown in the sync console)", ) device.add_argument( "--name", @@ -76,4 +75,25 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: "Omit to print the current label.", ) + # Org-shared skills. A member's submission becomes a proposal an admin + # reviews; an admin's merges straight into the shared set. Accounts that + # aren't in a shared organisation are told so plainly. + propose = sync_sub.add_parser( + "propose", + help="Share a skill with your organisation", + description=( + "Submit one of your skills to your organisation's shared set. If " + "you are an admin it is added directly; otherwise it becomes a " + "proposal for an admin to review. Accounts that aren't part of a " + "shared organisation don't have this workflow." + ), + ) + propose.add_argument("name", help="Skill name to share") + propose.add_argument( + "-m", + "--message", + default=None, + help="Optional message describing the change", + ) + sync_parser.set_defaults(func=cmd_sync) diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index b86d9a7dad9b6..b2f61e3ece8b6 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -261,7 +261,9 @@ class TestOrgPullIsWiredIn: root / "hermes_cli" / "subcommands" / "sync.py", root / "hermes_cli" / "subcommands" / "skills.py", ] - banned = re.compile(r"\(M[12]\)|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract") + banned = re.compile( + r"\(M[12]\)|\bHSP\b|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract" + ) for path in targets: for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1): if "help=" in line or "description=" in line: @@ -270,41 +272,41 @@ class TestOrgPullIsWiredIn: ) -class TestOrgSharingIsDiscoverable: - """`hermes sync` must point users at the org-sharing command. +class TestSkillSyncIsOneCommand: + """Every Skill Sync verb lives under `hermes sync` for launch. - Without this, there is no path from "I want to share this with my team" - to `hermes skills propose` — sync looks like the only sharing surface - while being personal-only. + The surface is deliberately encapsulated: one command to learn, one to + document, and top-level `sync` stays free of skill-management verbs that + belong elsewhere. `propose` in particular used to sit under `hermes + skills`, which split one feature across two commands. """ - def test_sync_usage_block_mentions_propose(self): + def _src(self, *parts): import pathlib - main_src = ( - pathlib.Path(__file__).resolve().parents[2] - / "hermes_cli" - / "main.py" + return ( + pathlib.Path(__file__).resolve().parents[2].joinpath(*parts) ).read_text(encoding="utf-8") - usage_start = main_src.index( - "usage: hermes sync " - ) - usage_block = main_src[usage_start : usage_start + 1200] - assert "hermes skills propose" in usage_block, ( - "`hermes sync` usage must point at the org-sharing command." + + def test_propose_is_a_sync_subcommand(self): + sync_src = self._src("hermes_cli", "subcommands", "sync.py") + assert '"propose"' in sync_src, ( + "`propose` must be a `hermes sync` subcommand." ) - def test_sync_parser_epilog_mentions_propose(self): - import pathlib + def test_propose_is_not_under_skills(self): + skills_src = self._src("hermes_cli", "subcommands", "skills.py") + assert '"propose"' not in skills_src, ( + "`propose` must NOT remain under `hermes skills` — Skill Sync is " + "one command for launch." + ) - src = ( - pathlib.Path(__file__).resolve().parents[2] - / "hermes_cli" - / "subcommands" - / "sync.py" - ).read_text(encoding="utf-8") - assert "hermes skills propose" in src, ( - "`hermes sync --help` must point at the org-sharing command." + def test_sync_usage_lists_propose(self): + main_src = self._src("hermes_cli", "main.py") + usage_start = main_src.index("usage: hermes sync ") + usage_block = main_src[usage_start : usage_start + 1400] + assert "propose" in usage_block, ( + "`hermes sync` usage must list the propose verb." ) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 0553dcb6e9328..e51ebc6860a18 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -1,13 +1,13 @@ -"""Tests for tools/skills_sync_client.py — the HSP/1 sync client. +"""Tests for tools/skills_sync_client.py — the Skill Sync client. Covers, against the frozen contract (~/src/specs/collective-wisdom/ -hsp-1-contract.md): +the sync wire contract): * content addressing (full 64-hex) + canonical JSON (§2.1, §2.5) * the DEV-PHASE gate (tool_gateway_admin) making sync inert * the M1-D opt-in default (nothing syncs without the sync flag) * object building (blob/tree/commit, exec mode, size limit) * push (upload + CAS), pull (materialize), and the three-way merge / 409 - conflict paths — all against an in-process mock HSP server. + conflict paths — all against an in-process mock sync server. The mock server implements the contract §3/§4 endpoint shapes with an in-memory object store + ref table. No live server, no network. @@ -25,7 +25,7 @@ import tools.skills_sync_client as ssc # --------------------------------------------------------------------------- -# In-process mock HSP/1 server (contract §3-§4) +# In-process mock sync server (read + write endpoints) # --------------------------------------------------------------------------- class _MockState: @@ -237,7 +237,7 @@ def _jwt(claims: dict) -> str: class TestAddressing: def test_full_64_hex_address(self): - addr = ssc.hsp_address(b"") + addr = ssc.wire_address(b"") # sha256 of empty is the well-known e3b0... digest, full 64 hex. assert addr == ( "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" @@ -245,9 +245,9 @@ class TestAddressing: assert len(addr.split(":", 1)[1]) == 64 def test_address_differs_from_local_truncated_namespace(self): - # OI-5: HSP full-64-hex must NOT equal the local truncated 16-hex form. + # The wire full-64-hex must NOT equal the local truncated 16-hex form. data = b"hello world" - full = ssc.hsp_address(data) + full = ssc.wire_address(data) truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16] assert full != truncated assert len(full.split(":")[1]) == 64 @@ -460,7 +460,7 @@ def synced_env(tmp_path, monkeypatch): class TestEndToEnd: def test_capabilities_version_check(self, mock_server): base, state = mock_server - client = ssc.HSPClient(base, "tok") + client = ssc.SyncClient(base, "tok") caps = client.capabilities() assert caps["hsp_version"] == "1" ssc._check_version(caps) # no raise @@ -468,14 +468,14 @@ class TestEndToEnd: def test_version_mismatch_raises(self, mock_server): base, state = mock_server state.hsp_version = "2" - client = ssc.HSPClient(base, "tok") - with pytest.raises(ssc.HSPError): + client = ssc.SyncClient(base, "tok") + with pytest.raises(ssc.SyncError): ssc._check_version(client.capabilities()) def test_push_uploads_and_cas(self, mock_server, synced_env): base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) result = ssc.push_skills(client, identity=identity) assert result["ok"] is True # HEAD ref advanced to our commit @@ -491,7 +491,7 @@ class TestEndToEnd: def test_push_then_pull_materializes(self, mock_server, synced_env, tmp_path, monkeypatch): base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) ssc.push_skills(client, identity=identity) # Simulate a fresh device: new skills dir, same server, same opt-in. @@ -513,7 +513,7 @@ class TestEndToEnd: def test_push_idempotent_reupload(self, mock_server, synced_env): base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) r1 = ssc.push_skills(client, identity=identity) n_objects = len(state.objects) # push again with no local change -> same head, objects already_present @@ -525,7 +525,7 @@ class TestEndToEnd: def test_conflict_nonoverlap_merges(self, mock_server, synced_env, monkeypatch): base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) # First push establishes a base head we record locally. first = ssc.push_skills(client, identity=identity) # Inject a divergent server head: change beta server-side so the next @@ -543,7 +543,7 @@ class TestEndToEnd: def test_conflict_true_overlap_writes_conflict_ref(self, mock_server, synced_env, monkeypatch): base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) ssc.push_skills(client, identity=identity) # Build a DIFFERENT server-side head for the SAME skill (alpha) so the @@ -642,7 +642,7 @@ class TestSyncManifest: # plane content. Read it back via read_manifest_of_root. base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) objs, root_hash, skill_map = ssc.snapshot_profile(["alpha", "beta"]) client.put_objects(objs.objects) @@ -661,7 +661,7 @@ class TestSyncManifest: # becomes opted in locally on pull, even if this device had it disabled. base, state = mock_server home, skills, identity = synced_env - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) # Device A pushes alpha+beta (manifest enables both). ssc.push_skills(client, identity=identity) @@ -857,7 +857,7 @@ class TestOrgEndToEnd: base, state = mock_server home, skills, identity = synced_env identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) result = ssc.propose_skill("alpha", client, identity=identity) assert result["ok"] is True assert result.get("merged") is True @@ -871,7 +871,7 @@ class TestOrgEndToEnd: home, skills, identity = synced_env # Seed an org HEAD as admin first. admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) seeded = ssc.propose_skill("alpha", client, identity=admin_ident) # Member edits beta and proposes: server converts to 202. @@ -896,7 +896,7 @@ class TestOrgEndToEnd: base, state = mock_server home, skills, identity = synced_env admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) ssc.propose_skill("alpha", client, identity=admin_ident) ssc.propose_skill("beta", client, identity=admin_ident) @@ -913,7 +913,7 @@ class TestOrgEndToEnd: base, state = mock_server home, skills, identity = synced_env admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) ssc.propose_skill("alpha", client, identity=admin_ident) result = ssc.pull_org_skills(client, identity=admin_ident) @@ -927,7 +927,7 @@ class TestOrgEndToEnd: base, state = mock_server home, skills, identity = synced_env ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) result = ssc.pull_org_skills(client, identity=ident) assert result["ok"] is True assert result["head"] is None @@ -938,7 +938,7 @@ class TestOrgEndToEnd: home, skills, identity = synced_env state.org_feature = False ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"} - client = ssc.HSPClient(base, identity["api_key"]) + client = ssc.SyncClient(base, identity["api_key"]) with pytest.raises(ssc.SyncInertError): ssc.propose_skill("alpha", client, identity=ident) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 5064b25a048ca..6d5f2a5fba482 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -639,7 +639,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]: return ( f"This skill is shared by your organisation. Your edit is " f"saved locally and will not be overwritten by org updates. " - f"Run `hermes skills propose {name}` to share it back." + f"Run `hermes sync propose {name}` to share it back." ) result = ssc.propose_skill(name) if result.get("proposal_pending"): @@ -652,7 +652,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]: logger.debug("auto-propose skipped for %s: %s", name, e) return ( f"Edit saved locally. Could not submit it to your organisation " - f"right now — run `hermes skills propose {name}` to retry." + f"right now — run `hermes sync propose {name}` to retry." ) @@ -668,7 +668,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona Now an edit lands in the mirror and is protected from being overwritten by the next org pull (see the baseline sidecar in skills_sync_client). It - reaches the organisation when the user runs `hermes skills propose`, or + reaches the organisation when the user runs `hermes sync propose`, or immediately if `sync.org_auto_propose` is on. Deletion is still refused: the mirror is a materialized view of the org @@ -688,7 +688,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona "organisation, so a local delete would just come back on " "the next sync. Ask an org admin to remove it for " "everyone. (Editing it IS allowed — your changes are kept " - "and can be proposed back with `hermes skills propose " + "and can be proposed back with `hermes sync propose " f"{name}`.)" ), } @@ -1438,7 +1438,7 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str: _skill_gate_bypass.reset(token) -# Debounce state for the HSP sync push hook. A burst of skill_manage writes +# Debounce state for the sync push hook. A burst of skill_manage writes # (e.g. create + several write_file calls) collapses into a single push after # a short quiet window, on a daemon timer so the agent write never blocks. _sync_push_timer = None @@ -1447,7 +1447,7 @@ _SYNC_PUSH_DEBOUNCE_S = 5.0 def _maybe_debounced_sync_push(skill_name: str) -> None: - """Schedule a debounced best-effort HSP push after a skill write. + """Schedule a debounced best-effort sync push after a skill write. Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth, no network). Otherwise (re)arm a daemon timer; the actual push runs through @@ -1586,7 +1586,7 @@ def skill_manage( except Exception: pass - # HSP sync push hook (debounced, best-effort). Fires only AFTER the + # Sync push hook (debounced, best-effort). Fires only AFTER the # write gate passed (staged/unapproved writes never reach here -- the # gate returns early above), so we never push un-reviewed content. # Inert unless the DEV-PHASE gate is open (tool_gateway_admin on the diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 7ea57d5480a86..eeb0591a430c5 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -680,7 +680,7 @@ def set_pinned(skill_name: str, pinned: bool) -> None: def set_sync(skill_name: str, sync: bool) -> None: - """Set the HSP-sync opt-in flag on a skill's usage record (M1-D). + """Set the sync opt-in flag on a skill's usage record. Sync is OPT-IN: nothing propagates to the sync plane unless the user marks a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by`` @@ -695,7 +695,7 @@ def set_sync(skill_name: str, sync: bool) -> None: def is_sync_enabled(skill_name: str) -> bool: - """Whether a skill is opted into HSP sync (``sync: true`` in its record).""" + """Whether a skill is opted into sync (``sync: true`` in its record).""" return get_record(skill_name).get("sync") is True diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 0bc604ac12e12..1bfa822a4f570 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -HSP/1 sync client -- Hermes Sync Protocol version 1, client (personal skill sync). +Skill Sync client -- the low-level sync layer. -This is the LOW-LEVEL sync layer. It builds content-addressed HSP objects -(blob/tree/commit) from local skills, talks the HSP/1 wire contract to a sync +This is the LOW-LEVEL sync layer. It builds content-addressed objects +(blob/tree/commit) from local skills, talks the sync wire contract to a sync plane (push objects + CAS a ref, pull the owner's HEAD, three-way merge on a 409), and is driven by: @@ -15,7 +15,7 @@ It lives beside ``tools/skills_sync.py`` (NOT under ``hermes_cli/``) so the low-level sync layer never imports the CLI -- same rule the bundled-skills sync module documents at ``skills_sync.py:43-50``. -Contract: ``~/src/specs/collective-wisdom/hsp-1-contract.md`` (HSP/1, frozen +Contract: the Skill Sync wire contract (version 1, frozen for Milestone 1). Endpoint shapes, object model, canonicalization, and status codes below all trace to that document. @@ -58,7 +58,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # Sync protocol constants -HSP_VERSION = "1" +# Wire protocol version. The over-the-wire names below (the `hsp_version` +# capability field and the `x-hsp-object-type` response header) are part of +# the deployed server contract and are NOT renamed with the product — the +# user-facing feature is "Skill Sync"; these are protocol identifiers. +WIRE_VERSION = "1" DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default # Object kinds (sync contract) @@ -77,7 +81,7 @@ ARTIFACT_TYPE_SKILL = "skill" # `sync-manifest` object convention (design notes). # # Per-skill sync opt-in ("this skill syncs / this one does not" -# opt-in state) is CONTENT inside the HSP object model, NOT a device-local flag +# opt-in state) is CONTENT inside the sync object model, NOT a device-local flag # or a mutable preference table. An owner's synced set is a small committed blob # named ``sync-manifest`` at the ROOT of the tree referenced by # ``refs/user//HEAD``, recording per-skill ``{name, enabled}``. Toggling @@ -158,14 +162,14 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: # --------------------------------------------------------------------------- # Content addressing # -# HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT +# The wire uses the FULL 64-hex sha256 digest. This is a DIFFERENT # namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846), # which is a truncated 16-hex digest used for local dedup. They must never be # conflated -- we compute full digests here. # --------------------------------------------------------------------------- -def hsp_address(data: bytes) -> str: - """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (sync contract).""" +def wire_address(data: bytes) -> str: + """Return ``sha256:<64-hex>`` -- the wire address of ``data``.""" return "sha256:" + hashlib.sha256(data).hexdigest() @@ -279,14 +283,14 @@ def dev_gate_open() -> bool: # --------------------------------------------------------------------------- # Sync-plane endpoint resolution # -# The HSP routes are mounted under /v1/sync/ (sync contract). The base URL is +# The sync routes are mounted under /v1/sync/. The base URL is # configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env); # it is NOT the inference base_url. When unset, sync is inert -- there is no # server to talk to yet (the server is being built in parallel). # --------------------------------------------------------------------------- def resolve_sync_base_url() -> Optional[str]: - """Resolve the HSP sync-plane base URL, or None when unconfigured. + """Resolve the sync-plane base URL, or None when unconfigured. Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``. Returns a base without a trailing slash (e.g. ``https://host``); the @@ -318,7 +322,7 @@ def resolve_sync_base_url() -> Optional[str]: # precedence as base_url: the HERMES_SYNC_* env var wins, else config.yaml # ``sync.*``, else a built-in default. # -# HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL) +# HERMES_SYNC_BASE_URL -> sync.base_url (the sync plane URL) # HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off) # HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (personal sync policy; default false # = opt-in. Set true to make @@ -383,7 +387,7 @@ def sync_org_auto_propose() -> bool: ``HERMES_SYNC_ORG_AUTO_PROPOSE`` -> ``sync.org_auto_propose`` -> False. False (default): edits to an org-shared skill stay LOCAL until the user - runs ``hermes skills propose ``. The skill keeps working with the + runs ``hermes sync propose ``. The skill keeps working with the edit applied; the organisation just doesn't see it yet. True: every local edit to an org skill is submitted to the org as a @@ -427,7 +431,7 @@ def _skills_dir() -> Path: def is_sync_eligible(skill_name: str) -> bool: - """Whether *skill_name* is a candidate for HSP sync (before the opt-in check). + """Whether *skill_name* is a candidate for sync (before the opt-in check). Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT hub-installed, NOT an external-dir skill, and NOT under the org mirror @@ -525,7 +529,7 @@ def _all_local_skill_names() -> List[str]: # --------------------------------------------------------------------------- -# Object building -- turn a skill directory into HSP blob/tree/commit objects +# Object building -- turn a skill directory into blob/tree/commit objects # # A skill dir becomes one tree (sync contract). Each file is a blob; each # subdir a nested tree. The profile-root tree (the sync contract: "a tree whose @@ -533,7 +537,7 @@ def _all_local_skill_names() -> List[str]: # --------------------------------------------------------------------------- class ObjectSet: - """Accumulates HSP objects to push: hash -> (kind, bytes). + """Accumulates objects to push: hash -> (kind, bytes). Deduped by content address, so identical blobs across skills upload once. """ @@ -542,7 +546,7 @@ class ObjectSet: self.objects: Dict[str, Tuple[str, bytes]] = {} def add(self, kind: str, data: bytes) -> str: - addr = hsp_address(data) + addr = wire_address(data) self.objects.setdefault(addr, (kind, data)) return addr @@ -551,7 +555,7 @@ class ObjectSet: def _file_mode(path: Path) -> str: - """Return the HSP tree mode for a regular file: ``exec`` if +x else ``file`` + """Return the tree mode for a regular file: ``exec`` if +x else ``file`` (contract §2.3). No symlinks / other modes are emitted.""" try: if path.stat().st_mode & (_stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH): @@ -562,7 +566,7 @@ def _file_mode(path: Path) -> str: def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> str: - """Recursively build HSP objects for *dir_path*; return the tree address. + """Recursively build objects for *dir_path*; return the tree address. Regular files become blobs; subdirectories become nested trees. Symlinks, sockets, and other special files are skipped (contract §2.3 security: no @@ -705,22 +709,22 @@ def set_device_name(name: str) -> str: # --------------------------------------------------------------------------- -# HSP/1 wire client +# Sync wire client # # Thin requests-based client for the endpoints in the sync contract- Uploads all # new objects (batch), then CAS-es the ref. A 409 returns the actual head for # the caller's three-way merge. Auth is the Nous bearer resolved above. # --------------------------------------------------------------------------- -class HSPError(RuntimeError): - """A non-recoverable HSP wire error (4xx that the client can't retry).""" +class SyncError(RuntimeError): + """A non-recoverable wire error (4xx that the client can't retry).""" def __init__(self, message: str, *, status: Optional[int] = None): super().__init__(message) self.status = status -class HSPConflict(RuntimeError): +class SyncConflict(RuntimeError): """CAS lost (409). ``actual`` is the current head to merge against (contract §4.4). NOT a rejection -- pushed objects are already durable.""" @@ -729,7 +733,7 @@ class HSPConflict(RuntimeError): self.actual = actual -class HSPClient: +class SyncClient: """Sync client bound to a base URL + bearer (routes under ``/v1/sync/``).""" @@ -751,7 +755,7 @@ class HSPClient: """GET /v1/sync/capabilities (sync contract). No auth required.""" r = self._session.get(self._url("capabilities"), timeout=self.timeout) if r.status_code != 200: - raise HSPError(f"capabilities failed: {r.status_code}", status=r.status_code) + raise SyncError(f"capabilities failed: {r.status_code}", status=r.status_code) return r.json() def get_refs(self, prefix: str) -> List[Dict[str, str]]: @@ -760,22 +764,22 @@ class HSPClient: self._url("refs"), params={"prefix": prefix}, timeout=self.timeout ) if r.status_code != 200: - raise HSPError(f"get_refs failed: {r.status_code}", status=r.status_code) + raise SyncError(f"get_refs failed: {r.status_code}", status=r.status_code) return (r.json() or {}).get("refs", []) def get_object(self, obj_hash: str) -> Tuple[str, bytes]: """GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes). - Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response + Kind comes from the object-type response header for tree/commit; a blob (application/octet-stream) is returned as ``blob``. """ r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout) if r.status_code == 404: - raise HSPError(f"object {obj_hash} not found", status=404) + raise SyncError(f"object {obj_hash} not found", status=404) if r.status_code == 403: - raise HSPError(f"object {obj_hash} not readable", status=403) + raise SyncError(f"object {obj_hash} not readable", status=403) if r.status_code != 200: - raise HSPError(f"get_object failed: {r.status_code}", status=r.status_code) + raise SyncError(f"get_object failed: {r.status_code}", status=r.status_code) kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB return kind, r.content @@ -783,14 +787,14 @@ class HSPClient: """Fetch a commit object and parse its canonical JSON.""" kind, data = self.get_object(commit_hash) if kind != KIND_COMMIT: - raise HSPError(f"{commit_hash} is {kind}, expected commit") + raise SyncError(f"{commit_hash} is {kind}, expected commit") return json.loads(data.decode("utf-8")) def get_tree_json(self, tree_hash: str) -> Dict[str, Any]: """Fetch a tree object and parse its canonical JSON.""" kind, data = self.get_object(tree_hash) if kind != KIND_TREE: - raise HSPError(f"{tree_hash} is {kind}, expected tree") + raise SyncError(f"{tree_hash} is {kind}, expected tree") return json.loads(data.decode("utf-8")) # -- write ------------------------------------------------------------- @@ -833,17 +837,17 @@ class HSPClient: timeout=self.timeout, ) if r.status_code == 413: - raise HSPError("object too large (413)", status=413) + raise SyncError("object too large (413)", status=413) if r.status_code == 422: - raise HSPError(f"hash_mismatch (422): {r.text}", status=422) + raise SyncError(f"hash_mismatch (422): {r.text}", status=422) if r.status_code not in (200, 201): - raise HSPError(f"put_objects failed: {r.status_code}", status=r.status_code) + raise SyncError(f"put_objects failed: {r.status_code}", status=r.status_code) return r.json() if r.content else {} def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]: """POST /v1/sync/refs/:name -- atomic compare-and-swap (sync contract). - Raises :class:`HSPConflict` (carrying the actual head) on 409. + Raises :class:`SyncConflict` (carrying the actual head) on 409. M2 (contract §11.5): a non-admin member's CAS on an org HEAD is never rejected — the server converts it to a proposal and returns @@ -862,16 +866,16 @@ class HSPClient: return {"proposal_pending": True, **body} if r.status_code == 409: actual = (r.json() or {}).get("actual", "") - raise HSPConflict(actual) + raise SyncConflict(actual) if r.status_code == 403: - raise HSPError("forbidden (403) -- owner/permission", status=403) + raise SyncError("forbidden (403) -- owner/permission", status=403) if r.status_code != 200: - raise HSPError(f"cas_ref failed: {r.status_code}", status=r.status_code) + raise SyncError(f"cas_ref failed: {r.status_code}", status=r.status_code) return r.json() if r.content else {} # --------------------------------------------------------------------------- -# HSP local sync STATE (client-local head bookkeeping, FULL-digest namespace) +# Local sync STATE (client-local head bookkeeping, FULL-digest namespace) # # Records the last commit HEAD we pushed/pulled and, per synced skill, the tree # hash of the on-disk content at that point. Distinct from the bundled manifest @@ -894,7 +898,7 @@ def _legacy_sync_state_path() -> Path: def read_sync_state() -> Dict[str, Any]: - """Read the local HSP sync state. Returns a default on missing/corrupt. + """Read the local sync state. Returns a default on missing/corrupt. Shape: ``{"head": "sha256:...|null", "skills": {name: {tree, commit}}}``. ``head`` is the last profile-root HEAD commit we reconciled with. @@ -933,7 +937,7 @@ def read_sync_state() -> Dict[str, Any]: def write_sync_state(data: Dict[str, Any]) -> None: - """Write the local HSP sync state atomically. Best-effort.""" + """Write the local sync state atomically. Best-effort.""" import tempfile path = _sync_state_path() @@ -957,11 +961,11 @@ def write_sync_state(data: Dict[str, Any]) -> None: # --------------------------------------------------------------------------- -# Tree materialization (pull) -- write an HSP tree back to a skill directory +# Tree materialization (pull) -- write a tree back to a skill directory # --------------------------------------------------------------------------- -def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None: - """Write the HSP tree at *tree_hash* into *dest* (created if needed). +def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None: + """Write the tree at *tree_hash* into *dest* (created if needed). Blobs become files (with +x restored for ``exec`` mode), nested trees become subdirectories. Does NOT delete files absent from the tree -- the @@ -1017,7 +1021,7 @@ def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]: def snapshot_profile( skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES ) -> Tuple[ObjectSet, str, Dict[str, str]]: - """Build all HSP objects for *skill_names* + the profile-root tree. + """Build all objects for *skill_names* + the profile-root tree. Returns ``(objects, root_tree_hash, skill_tree_map)`` where ``skill_tree_map`` is ``{skill_name: tree_hash}``. Skills whose blobs @@ -1074,7 +1078,7 @@ def snapshot_profile( def _build_root_tree( node: Dict[str, Any], objects: ObjectSet, *, manifest_hash: Optional[str] = None ) -> str: - """Recursively canonicalize the nested root structure into HSP trees. + """Recursively canonicalize the nested root structure into trees. ``manifest_hash`` (only passed at the top level) adds a root-level ``sync-manifest`` BLOB entry (design.md §2.8) alongside the skill subtrees. @@ -1117,12 +1121,12 @@ def user_conflict_ref(owner: str, n: int) -> str: return f"refs/user/{owner}/conflict/{n}" -def _root_tree_of_commit(client: "HSPClient", commit_hash: str) -> str: +def _root_tree_of_commit(client: "SyncClient", commit_hash: str) -> str: """Return the tree hash referenced by a commit.""" return client.get_commit_json(commit_hash)["tree"] -def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, str]: +def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, str]: """Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``. A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the @@ -1150,7 +1154,7 @@ def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, def read_manifest_of_root( - client: "HSPClient", root_tree_hash: str + client: "SyncClient", root_tree_hash: str ) -> Optional[Dict[str, bool]]: """Read the ``sync-manifest`` blob at the root of *root_tree_hash* into ``{name: enabled}`` (design.md §2.8), or ``None`` if there is no manifest @@ -1178,10 +1182,13 @@ def read_manifest_of_root( def _check_version(caps: Dict[str, Any]) -> None: """Reject an incompatible server major version (sync contract).""" - ver = str(caps.get("hsp_version") or "") + ver = str(caps.get("hsp_version") or "") # wire field name major = ver.split(".", 1)[0] - if major != HSP_VERSION: - raise HSPError(f"incompatible HSP version {ver!r} (client speaks {HSP_VERSION})") + if major != WIRE_VERSION: + raise SyncError( + f"this server speaks sync version {ver!r}, but this Hermes speaks " + f"{WIRE_VERSION} — update Hermes to sync with it" + ) # --------------------------------------------------------------------------- @@ -1189,7 +1196,7 @@ def _check_version(caps: Dict[str, Any]) -> None: # --------------------------------------------------------------------------- def push_skills( - client: Optional["HSPClient"] = None, + client: Optional["SyncClient"] = None, *, skill_names: Optional[List[str]] = None, identity: Optional[Dict[str, Any]] = None, @@ -1208,7 +1215,7 @@ def push_skills( base = resolve_sync_base_url() if not base: return {"ok": False, "reason": "no sync base url configured", "noop": True} - client = HSPClient(base, identity["api_key"]) + client = SyncClient(base, identity["api_key"]) if skill_names is None: skill_names = list_synced_skill_names() @@ -1245,7 +1252,7 @@ def push_skills( manifest["root"] = root_hash write_sync_state(manifest) return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)} - except HSPConflict as conflict: + except SyncConflict as conflict: return _resolve_push_conflict( client, identity, conflict.actual, root_hash, commit_hash, objects, skill_names, message, base_head, @@ -1273,7 +1280,7 @@ def push_skills( # --------------------------------------------------------------------------- def _resolve_push_conflict( - client: "HSPClient", + client: "SyncClient", identity: Dict[str, Any], actual_head: str, our_root: str, @@ -1321,7 +1328,7 @@ def _resolve_push_conflict( conflict_ref = user_conflict_ref(owner, n) try: client.cas_ref(conflict_ref, None, our_commit) - except HSPConflict: + except SyncConflict: pass # someone else grabbed this index; the head still exists return { "ok": False, @@ -1353,7 +1360,7 @@ def _resolve_push_conflict( client.put_objects(merge_objects.objects) try: client.cas_ref(user_head_ref(owner), actual_head, merge_commit) - except HSPConflict as c2: + except SyncConflict as c2: return { "ok": False, "conflict": True, @@ -1388,7 +1395,7 @@ def _merge_skill(base: Optional[str], ours: Optional[str], theirs: Optional[str] def _assemble_root_from_skill_trees( - client: "HSPClient", skill_trees: Dict[str, str], objects: "ObjectSet" + client: "SyncClient", skill_trees: Dict[str, str], objects: "ObjectSet" ) -> str: """Build a profile-root tree object from ``{posix_rel_path: tree_hash}``. @@ -1406,11 +1413,11 @@ def _assemble_root_from_skill_trees( return _build_root_tree(root, objects) -def _next_conflict_index(client: "HSPClient", owner: str) -> int: +def _next_conflict_index(client: "SyncClient", owner: str) -> int: """Pick the next free conflict ref index for the owner.""" try: refs = client.get_refs(f"refs/user/{owner}/conflict/") - except HSPError: + except SyncError: return 1 used = [] for r in refs: @@ -1426,7 +1433,7 @@ def _next_conflict_index(client: "HSPClient", owner: str) -> int: # --------------------------------------------------------------------------- def pull_skills( - client: Optional["HSPClient"] = None, + client: Optional["SyncClient"] = None, *, identity: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -1445,7 +1452,7 @@ def pull_skills( base = resolve_sync_base_url() if not base: return {"ok": False, "reason": "no sync base url configured", "noop": True} - client = HSPClient(base, identity["api_key"]) + client = SyncClient(base, identity["api_key"]) caps = client.capabilities() _check_version(caps) @@ -1652,7 +1659,7 @@ def list_org_skill_names() -> List[str]: # here is inert (org_sync_available() False; pull/propose raise SyncInertError) # and the personal personal sync experience is untouched. # -# TRAJECTORY (Ben): `hermes skills propose` is the org sharing MVP surface; proposal is +# `hermes sync propose` is the org sharing surface; proposal is # intended to become largely automated later (curator/background hooks driving # the same propose_skill() path). Keep this callable non-interactive. # --------------------------------------------------------------------------- @@ -1702,7 +1709,7 @@ def _org_dir() -> Path: def pull_org_skills( - client: Optional["HSPClient"] = None, + client: Optional["SyncClient"] = None, *, identity: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -1722,7 +1729,7 @@ def pull_org_skills( base_url = resolve_sync_base_url() if not base_url: raise SyncInertError("no sync base URL configured") - client = HSPClient(base_url, identity["api_key"]) + client = SyncClient(base_url, identity["api_key"]) caps = client.capabilities() _check_version(caps) @@ -1916,7 +1923,7 @@ def _write_org_provenance(org_id: str, data: Dict[str, Any]) -> None: def propose_skill( skill_name: str, - client: Optional["HSPClient"] = None, + client: Optional["SyncClient"] = None, *, identity: Optional[Dict[str, Any]] = None, message: Optional[str] = None, @@ -1942,7 +1949,7 @@ def propose_skill( base_url = resolve_sync_base_url() if not base_url: raise SyncInertError("no sync base URL configured") - client = HSPClient(base_url, identity["api_key"]) + client = SyncClient(base_url, identity["api_key"]) caps = client.capabilities() _check_version(caps) @@ -1953,10 +1960,10 @@ def propose_skill( # Locate the local skill directory (personal namespace, NOT _org/). rel = _skill_rel_path(skill_name) if rel is None: - raise HSPError(f"skill '{skill_name}' not found under the skills dir") + raise SyncError(f"skill '{skill_name}' not found under the skills dir") skill_dir = _skills_dir() / rel if not (skill_dir / "SKILL.md").exists(): - raise HSPError(f"skill '{skill_name}' has no SKILL.md") + raise SyncError(f"skill '{skill_name}' has no SKILL.md") # Build the proposed skill tree. objects = ObjectSet() diff --git a/tools/skills_tool.py b/tools/skills_tool.py index b310cda8a468c..9943db8160bb5 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1617,7 +1617,7 @@ def skill_view( "Your edits are kept locally\n" "> and are never overwritten by org updates; share " "them back with\n" - "> `hermes skills propose` (or automatically, if your " + "> `hermes sync propose` (or automatically, if your " "org enables it).\n\n" ) rendered_content = header + rendered_content From f9c4d835f9a92188dc190de2e30ead7baf20120f Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 07:57:25 +1000 Subject: [PATCH 30/36] refactor(sync): name the access gate for what it is (Nous admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client called its gate "the DEV-PHASE gate (tool_gateway_admin)", which reads as though Skill Sync is gated on an unrelated service's admin right. It isn't. NAS populates that claim from Permissions.ADMIN_ACCESS — the global portal admin permission that guards /admin/* — so the gate is "is this user a Nous admin?". The claim is simply named for its first consumer, the tool gateway. Renamed on this side to say what it means, while keeping the wire string (other services read it): - DEV_GATE_CLAIM -> NOUS_ADMIN_CLAIM (value unchanged: "tool_gateway_admin", with a comment recording why the wire name differs). - identity/status key dev_gate_ok -> nous_admin, across the client, the CLI consumers, and the tests. - The module docstring now states where the claim comes from, that the wire name is misleading, and that this gate is pre-launch containment rather than the shipping entitlement — admin status conflates "may administer Nous" with "has Skill Sync enabled" and has no middle setting for a beta cohort. Choosing the real entitlement is left as a separate decision. Naming only — no behaviour change, and no change to which accounts can sync. The user-facing messages stay deliberately vague ("not enabled for your account yet") rather than telling users they need portal admin. Verified: 2346 passed / 0 failed across 56 suites via scripts/run_tests.sh; `hermes sync status` against a live token reports "nous_admin": true. Zero stale dev_gate_ok / DEV_GATE_CLAIM references remain. --- hermes_cli/main.py | 4 +- tests/tools/test_skills_sync_client.py | 12 +++--- tools/skill_manager_tool.py | 4 +- tools/skills_sync_client.py | 60 +++++++++++++++----------- 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 43de6abe69996..ee45af38930c5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4554,7 +4554,7 @@ def cmd_sync(args): ) if not status.get("logged_in"): print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr) - elif not status.get("dev_gate_ok"): + elif not status.get("nous_admin"): print( "\nSync is not enabled for your account yet.", file=sys.stderr, @@ -4579,7 +4579,7 @@ def cmd_sync(args): except ssc.SyncInertError as e: print(f"sync inert: {e}", file=sys.stderr) return 1 - if not identity.get("dev_gate_ok"): + if not identity.get("nous_admin"): print( "sync unavailable: not enabled for your account yet.", file=sys.stderr, diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index e51ebc6860a18..6e90f5f327831 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -3,7 +3,7 @@ Covers, against the frozen contract (~/src/specs/collective-wisdom/ the sync wire contract): * content addressing (full 64-hex) + canonical JSON (§2.1, §2.5) - * the DEV-PHASE gate (tool_gateway_admin) making sync inert + * the access gate (Nous admin) making sync inert * the M1-D opt-in default (nothing syncs without the sync flag) * object building (blob/tree/commit, exec mode, size limit) * push (upload + CAS), pull (materialize), and the three-way merge / 409 @@ -265,7 +265,7 @@ class TestAddressing: # --------------------------------------------------------------------------- -# DEV-PHASE gate (tool_gateway_admin) + M1-D opt-in +# Access gate (Nous admin) + per-skill opt-in # --------------------------------------------------------------------------- class TestDevGate: @@ -280,7 +280,7 @@ class TestDevGate: monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", lambda **kw: {"api_key": token, "base_url": "https://x"}) ident = ssc.resolve_identity() - assert ident["dev_gate_ok"] is True + assert ident["nous_admin"] is True assert ident["owner"] == "user1" def test_gate_closed_without_claim(self, monkeypatch): @@ -289,7 +289,7 @@ class TestDevGate: monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", lambda **kw: {"api_key": token, "base_url": "https://x"}) ident = ssc.resolve_identity() - assert ident["dev_gate_ok"] is False + assert ident["nous_admin"] is False def test_gate_closed_when_claim_false(self, monkeypatch): token = _jwt({"sub": "u", "tool_gateway_admin": False}) @@ -453,7 +453,7 @@ def synced_env(tmp_path, monkeypatch): token = _jwt({"sub": "owner1", "tool_gateway_admin": True}) identity = {"api_key": token, "base_url": "http://x", "owner": "owner1", - "dev_gate_ok": True, "claims": {}} + "nous_admin": True, "claims": {}} return home, skills, identity @@ -811,7 +811,7 @@ def _org_identity(role=None, org_id="org-1", owner="owner1"): claims["org_role"] = role token = _jwt(claims) return {"api_key": token, "base_url": "http://x", "owner": owner, - "dev_gate_ok": True, "claims": claims, + "nous_admin": True, "claims": claims, **({"org_id": org_id, "org_role": role} if role else {})} diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 6d5f2a5fba482..1482e0fa68cbe 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -1451,7 +1451,7 @@ def _maybe_debounced_sync_push(skill_name: str) -> None: Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth, no network). Otherwise (re)arm a daemon timer; the actual push runs through - ``skills_sync_client.maybe_push_skills`` which enforces the DEV-PHASE gate + ``skills_sync_client.maybe_push_skills`` which enforces the access gate and swallows all errors. Never blocks the caller (M1-C: agent never blocks on sync). """ @@ -1589,7 +1589,7 @@ def skill_manage( # Sync push hook (debounced, best-effort). Fires only AFTER the # write gate passed (staged/unapproved writes never reach here -- the # gate returns early above), so we never push un-reviewed content. - # Inert unless the DEV-PHASE gate is open (tool_gateway_admin on the + # Inert unless the access gate is open (the user is a Nous admin on the # token), a sync base URL is configured, and the skill is opted into # sync. Debounced so a burst of edits collapses to one push. Never # raises -- an agent write must never block on sync (M1-C invariant). diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 1bfa822a4f570..812b3b9ad20e3 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -19,15 +19,25 @@ Contract: the Skill Sync wire contract (version 1, frozen for Milestone 1). Endpoint shapes, object model, canonicalization, and status codes below all trace to that document. ---- DEV-PHASE GATE (Milestone 1) ----------------------------------------- -Client sync is INERT (no push, no pull, no-op) unless the resolved Nous -identity's access token carries ``tool_gateway_admin === true``. That claim is -minted by NAS (access-token-issuer.ts:312) and rides on the same bearer -``resolve_nous_runtime_credentials()`` returns. We decode the JWT payload -(no signature verification -- the server re-verifies) and check the claim -before doing any sync work. This is a temporary dev gate for the M1 rollout; -remove it (or replace it with a real ``sync:*`` scope / config toggle) when -sync ships to all users. +--- ACCESS GATE (pre-launch) --------------------------------------------- +Client sync is INERT (no push, no pull, no-op) unless the signed-in user is a +**Nous admin**. We read that off the access token, which rides on the same +bearer ``resolve_nous_runtime_credentials()`` returns; we decode the JWT +payload (no signature verification -- the server re-verifies) and check the +claim before doing any sync work. + +NAMING: the claim on the wire is ``tool_gateway_admin``, which is misleading +-- it is NOT a tool-gateway-specific right. NAS populates it from +``Permissions.ADMIN_ACCESS`` (access-token-issuer.ts), the same global portal +admin permission that guards ``/admin/*``; the claim is simply named for its +first consumer. We keep the wire name (other services read it) but call it +what it means everywhere on this side. + +This gate is pre-launch containment, not the shipping entitlement. Admin +status conflates "may administer Nous" with "has Skill Sync enabled", and has +no middle setting for a beta cohort -- opening it up would mean handing out +portal admin. Replace it with a real entitlement (a ``sync:*`` scope, a tier +check, or a per-cohort feature flag) before shipping to users. --- OPT-IN DEFAULT (M1-D, provisional) ----------------------------------- Nothing syncs unless the user marks a skill for sync. The user's local intent @@ -196,18 +206,20 @@ def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: # We reuse resolve_nous_runtime_credentials() for the bearer (it honors the # cross-process file lock + portal host allowlist and refreshes as needed -- # we do NOT reimplement refresh). The returned api_key IS the JWT bearer; we -# decode its payload (unverified) to read the dev gate claim. +# decode its payload (unverified) to read the access-gate claim. # --------------------------------------------------------------------------- # Dev-phase gate claim (NAS access-token-issuer.ts:312). Sync is inert unless # the resolved token carries this claim === true. Remove when sync ships GA. -DEV_GATE_CLAIM = "tool_gateway_admin" +# Wire claim name is NAS's; it means "this user is a Nous admin" +# (populated from Permissions.ADMIN_ACCESS), NOT a tool-gateway right. +NOUS_ADMIN_CLAIM = "tool_gateway_admin" class SyncInertError(RuntimeError): """Raised (and caught by the gate-and-swallow hooks) when sync must no-op: - not logged in, no bearer, or the dev-phase gate claim is absent/false. + not logged in, no bearer, or the caller is not a Nous admin. """ @@ -234,7 +246,7 @@ def _decode_jwt_payload_unverified(token: str) -> Dict[str, Any]: def resolve_identity() -> Dict[str, Any]: """Resolve the Nous bearer + owner + dev-gate flag. - Returns a dict: ``{api_key, base_url, owner, dev_gate_ok, claims}``. + Returns a dict: ``{api_key, base_url, owner, nous_admin, claims}``. Raises :class:`SyncInertError` if not logged in / no bearer. ``owner`` is the token-verified subject; the server derives the real owner @@ -259,12 +271,12 @@ def resolve_identity() -> Dict[str, Any]: or claims.get("tid") or "unknown" ) - dev_gate_ok = claims.get(DEV_GATE_CLAIM) is True + nous_admin = claims.get(NOUS_ADMIN_CLAIM) is True return { "api_key": api_key, "base_url": (creds or {}).get("base_url"), "owner": str(owner), - "dev_gate_ok": dev_gate_ok, + "nous_admin": nous_admin, "claims": claims, } @@ -272,7 +284,7 @@ def resolve_identity() -> Dict[str, Any]: def dev_gate_open() -> bool: """Whether the access gate permits sync. Never raises.""" try: - return bool(resolve_identity().get("dev_gate_ok")) + return bool(resolve_identity().get("nous_admin")) except SyncInertError: return False except Exception as e: @@ -375,7 +387,7 @@ def sync_feature_enabled() -> bool: ``HERMES_SYNC_ENABLED`` -> ``sync.enabled`` -> False. This is the master switch a Hermes Cloud deployment sets to opt its instances into sync by default. It is checked by the gate-and-swallow entrypoints IN ADDITION to - the dev-phase token gate and a configured base URL — all three must hold for + the Nous-admin token gate and a configured base URL — all three must hold for background sync to run. """ return _sync_config_bool("HERMES_SYNC_ENABLED", "enabled", default=False) @@ -1533,7 +1545,7 @@ def _opted_in_rel_paths() -> List[str]: # maybe_pull_skills / maybe_push_skills clone the shape of the curator's # maybe_run_curator (agent/curator.py:1998): best-effort, never raise, return # a result dict or None. The access gate is checked first -- sync is inert -# (no push, no pull, no-op) unless tool_gateway_admin === true on the token. +# (no push, no pull, no-op) unless the signed-in user is a Nous admin. # --------------------------------------------------------------------------- def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[str, Any]]: @@ -1541,8 +1553,8 @@ def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[st Never raises. Called from the debounced skill_manage push hook.""" try: identity = resolve_identity() - if not identity.get("dev_gate_ok"): - return None # access gate: inert without tool_gateway_admin + if not identity.get("nous_admin"): + return None # access gate: inert unless the user is a Nous admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1561,8 +1573,8 @@ def maybe_pull_skills() -> Optional[Dict[str, Any]]: + CLI startup).""" try: identity = resolve_identity() - if not identity.get("dev_gate_ok"): - return None # access gate: inert without tool_gateway_admin + if not identity.get("nous_admin"): + return None # access gate: inert unless the user is a Nous admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1576,7 +1588,7 @@ def maybe_pull_skills() -> Optional[Dict[str, Any]]: def sync_status() -> Dict[str, Any]: """Return a status snapshot for ``hermes sync status``. Never raises.""" status: Dict[str, Any] = { - "dev_gate_ok": False, + "nous_admin": False, "logged_in": False, "feature_enabled": sync_feature_enabled(), "default_opt_in": sync_default_opt_in(), @@ -1598,7 +1610,7 @@ def sync_status() -> Dict[str, Any]: identity = resolve_identity() status["logged_in"] = True status["owner"] = identity.get("owner") - status["dev_gate_ok"] = bool(identity.get("dev_gate_ok")) + status["nous_admin"] = bool(identity.get("nous_admin")) except SyncInertError: pass except Exception as e: From e327eaa2a070bf6788d179e73c9ae5096d652e94 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 08:14:03 -0700 Subject: [PATCH 31/36] feat(sync): default the sync plane to production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill Sync had no default base URL, so a user with no `sync.base_url` in config.yaml and no HERMES_SYNC_BASE_URL got: sync inert: no sync base URL configured (config.yaml sync.base_url or HERMES_SYNC_BASE_URL). Every sync command was unusable out of the box. The URL was left unset because the plane did not exist yet when the client was written; it does now. - Adds DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" and returns it as the last step of resolve_sync_base_url(). - Resolution order is unchanged otherwise: HERMES_SYNC_BASE_URL -> config.yaml sync.base_url -> production default. The env var and config key now exist to point a dev/staging build at another plane rather than to make the feature work at all. - Follows the existing precedent for production endpoints in this codebase (DEFAULT_NOUS_PORTAL_URL in hermes_cli/auth.py, HERMES_DIAGNOSTICS_BASE_URL in diagnostics_upload.py): a module constant with env/config override. The "no sync base URL configured" guards are kept — they are now unreachable in practice but remain correct if the default is ever blanked. Tests: 3 new — the default is returned when nothing is configured, config still overrides it, and the constant is a bare https origin (no trailing slash, no path) since the client appends /v1/sync/. 2349 passed / 0 failed across 56 suites via scripts/run_tests.sh. Verified against a temp HERMES_HOME with no config: resolves to the production plane; HERMES_SYNC_BASE_URL and sync.base_url both still win, and trailing slashes are stripped. --- tests/tools/test_skills_sync_client.py | 27 ++++++++++++++++++++++++++ tools/skills_sync_client.py | 27 ++++++++++++++++---------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 6e90f5f327831..ec7e68d933126 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -694,6 +694,33 @@ class TestEnvConfig: monkeypatch.setenv("HERMES_SYNC_BASE_URL", "https://plane.example/") assert ssc.resolve_sync_base_url() == "https://plane.example" + def test_base_url_defaults_to_production(self, monkeypatch): + # With nothing configured a user must still reach the real plane — + # otherwise every sync command fails with "no base URL configured". + monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False) + assert ssc.resolve_sync_base_url() == ssc.DEFAULT_SYNC_BASE_URL + + def test_default_is_a_bare_https_origin(self): + # The client appends /v1/sync/, so the default must be a scheme+host + # origin with no trailing slash and no path. + from urllib.parse import urlparse + + parsed = urlparse(ssc.DEFAULT_SYNC_BASE_URL) + assert parsed.scheme == "https" + assert parsed.netloc + assert parsed.path == "" + assert not ssc.DEFAULT_SYNC_BASE_URL.endswith("/") + + def test_config_overrides_default(self, monkeypatch): + monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"sync": {"base_url": "https://cfg.example/"}}, + raising=False, + ) + assert ssc.resolve_sync_base_url() == "https://cfg.example" + def test_feature_enabled_env(self, monkeypatch): # Default off. monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False) diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 812b3b9ad20e3..90fe360e9814f 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -295,18 +295,25 @@ def dev_gate_open() -> bool: # --------------------------------------------------------------------------- # Sync-plane endpoint resolution # -# The sync routes are mounted under /v1/sync/. The base URL is -# configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env); -# it is NOT the inference base_url. When unset, sync is inert -- there is no -# server to talk to yet (the server is being built in parallel). +# The sync routes are mounted under /v1/sync/. The base URL defaults to the +# production plane, so a normal user configures nothing; config.yaml +# sync.base_url (or the HERMES_SYNC_BASE_URL bridge env) overrides it to point +# a dev/staging build at another plane. It is NOT the inference base_url. # --------------------------------------------------------------------------- -def resolve_sync_base_url() -> Optional[str]: - """Resolve the sync-plane base URL, or None when unconfigured. +#: Production Skill Sync plane. Overridable per the resolution order below. +DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" - Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``. - Returns a base without a trailing slash (e.g. ``https://host``); the - ``/v1/sync/`` prefix is appended by the client. +def resolve_sync_base_url() -> Optional[str]: + """Resolve the sync-plane base URL. + + Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url`` -> + the production plane. Returns a base without a trailing slash (e.g. + ``https://host``); the ``/v1/sync/`` prefix is appended by the client. + + The production default means a normal user never configures a URL — the + env var and config key exist to point a dev/staging build at another + plane. Returns None only if the default is somehow blanked out. """ env = os.getenv("HERMES_SYNC_BASE_URL") if env and env.strip(): @@ -324,7 +331,7 @@ def resolve_sync_base_url() -> Optional[str]: return base.strip().rstrip("/") except Exception as e: logger.debug("skills_sync_client: config sync.base_url read failed: %s", e) - return None + return DEFAULT_SYNC_BASE_URL or None # --------------------------------------------------------------------------- From 33833e232e5195ce55722bafa50701c52aa24365 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 12:03:00 -0700 Subject: [PATCH 32/36] fix(relay): apply the Slack thread anchor on the media lane too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM thread-anchor contract was resolved only in send(). _send_media() — backing send_image, send_image_file, send_voice, send_video and send_document — passed reply_to straight to the frame and never touched metadata, so attachments egressing through the same connector-side Slack sender got both failure shapes this branch set out to remove: flat mode → reply_to survives, the image threads UNDER the user's DM message (the original reported symptom) thread mode → no metadata.thread_id, and threadTs() never reads reply_to, so the image lands in the home channel instead of the per-message thread Both are reachable: gateway/run.py delivers agent artifacts through send_voice/send_document. Extract the three steps that must always happen together (mode gate, mirrored reply_to_message_id strip, metadata promotion) into _apply_slack_thread_anchor and route BOTH lanes through it, so text and media cannot drift again. The media lane copies caller metadata rather than mutating it — these helpers are called in loops with a shared mapping. Also fold send_typing/stop_typing's duplicated status-anchor blocks into _with_status_thread_anchor. They had already drifted (stop_typing omitted the platform check) and the clear must target the thread the heartbeat set or the status line sticks until Slack's own timeout. Tests: media lane pinned in both modes plus the channel and no-caller-mutation cases; verified as real by reverting the fix and watching them fail. --- gateway/relay/adapter.py | 148 +++++++++++------- .../relay/test_relay_slack_dm_streaming.py | 98 +++++++++++- 2 files changed, 191 insertions(+), 55 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index d88815d676ac8..7c61ff4763d3b 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -904,32 +904,12 @@ class RelayAdapter(BasePlatformAdapter): if self._transport is None: return SendResult(success=False, error="no transport") # Native _resolve_thread_ts parity: a Slack DM reply must post flat at - # the DM root, not threaded under the triggering message. Drop the - # synthetic self-anchor from BOTH the top-level reply_to and the mirrored - # metadata.reply_to_message_id so the connector can't thread on either. - effective_reply_to = self._resolve_reply_to_for_send( + # the DM root, not threaded under the triggering message. One shared + # helper resolves the anchor for EVERY egress lane (see + # _apply_slack_thread_anchor) so the text and media lanes cannot drift. + effective_reply_to = self._apply_slack_thread_anchor( chat_id, reply_to, send_metadata ) - if effective_reply_to is None and reply_to is not None: - send_metadata.pop("reply_to_message_id", None) - # The connector's Slack sender THREADS ON METADATA ONLY — - # threadTs() reads metadata.thread_id/thread_ts and never looks at - # the frame's reply_to. A send whose only threading signal is - # reply_to (base.py's final-reply and fallback lanes build metadata - # from source.thread_id = None for a top-level DM) would post to the - # home channel even though _resolve_reply_to_for_send kept the - # anchor. Promote the surviving anchor into metadata.thread_id so - # the wire carries it where the connector actually reads it. Only - # when the mode gate kept the anchor (thread-per-message / real - # thread) — flat mode already nulled effective_reply_to above. - if ( - effective_reply_to is not None - and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value - and not ( - send_metadata.get("thread_id") or send_metadata.get("thread_ts") - ) - ): - send_metadata["thread_id"] = str(effective_reply_to) result = await self._transport.send_outbound( { "op": "send", @@ -1010,6 +990,80 @@ class RelayAdapter(BasePlatformAdapter): # Flat mode: synthetic DM self-anchor — post flat at the DM root. return None + def _apply_slack_thread_anchor( + self, + chat_id: str, + reply_to: Optional[str], + metadata: Dict[str, Any], + *, + mirror_key: str = "reply_to_message_id", + ) -> Optional[str]: + """Resolve the outbound Slack thread anchor for ONE egress frame. + + The single choke point every send lane goes through — text (``send``) + and media (``send_media``) alike. It does three things that must always + happen together, and previously only happened on the text lane: + + 1. Mode gate: ``_resolve_reply_to_for_send`` drops the synthetic DM + self-anchor in flat mode, keeps it in thread-per-message mode. + 2. Mirror strip: when the anchor is dropped, remove the mirrored + ``metadata.reply_to_message_id`` too, so the connector cannot + thread on the copy we forgot about. + 3. Anchor promotion: the connector's Slack sender THREADS ON METADATA + ONLY — ``threadTs()`` reads ``metadata.thread_id``/``thread_ts`` + and never looks at the frame's ``reply_to``. A surviving anchor is + promoted into ``metadata.thread_id`` or the message silently lands + in the home channel instead of the per-message thread. + + ``metadata`` is mutated in place; the effective ``reply_to`` is + returned. Non-Slack and non-DM chats are untouched by (1), and (3) is + Slack-only, so other fronted platforms keep their existing behaviour. + """ + effective_reply_to = self._resolve_reply_to_for_send( + chat_id, reply_to, metadata + ) + if effective_reply_to is None and reply_to is not None: + metadata.pop(mirror_key, None) + if ( + effective_reply_to is not None + and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value + and not (metadata.get("thread_id") or metadata.get("thread_ts")) + ): + metadata["thread_id"] = str(effective_reply_to) + return effective_reply_to + + def _with_status_thread_anchor( + self, chat_id: str, metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Copy ``metadata`` with the typing/status thread anchor applied. + + Slack's status line is THREAD-scoped: the connector's typing case + no-ops without a thread anchor, and the typing lane's metadata + (base.py ``_thread_metadata_for_source``) carries none for a top-level + DM (``source.thread_id`` is None). Synthesize it from the per-chat + inbound-ts cache, exactly as native ``send_typing`` resolves + ``thread_ts`` from ``metadata.message_id``. + + Unconditional across both modes: in flat mode the send lane strips its + own anchors (see ``_apply_slack_thread_anchor``), so the status anchor + cannot leak into reply placement, and ``setStatus`` clears without + leaving a message artifact. + + Shared by ``send_typing`` and ``stop_typing`` — the clear MUST target + the same thread the heartbeat set, or the status line sticks until + Slack's own timeout. Keeping one implementation is what guarantees it. + """ + md = dict(metadata or {}) + if ( + not (md.get("thread_id") or md.get("thread_ts")) + and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value + and self._chat_type_by_chat.get(str(chat_id)) == "dm" + ): + anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) + if anchor: + md["thread_id"] = anchor + return md + async def edit_message( self, chat_id: str, @@ -1079,22 +1133,7 @@ class RelayAdapter(BasePlatformAdapter): # like native send_typing resolves thread_ts from metadata.message_id. # Flat mode (reply_in_thread=false) keeps the no-anchor no-op: there # is no thread and must not be one (#18859). - md = dict(metadata or {}) - if ( - not (md.get("thread_id") or md.get("thread_ts")) - and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value - and self._chat_type_by_chat.get(str(chat_id)) == "dm" - ): - # Thread mode: status targets the per-message thread. - # Flat mode: the status STILL anchors to the triggering ts — - # setStatus renders in the footer space and clears without a - # message artifact, and flat sends strip their anchors - # so reply placement cannot inherit it. Unconditional: liveliness - # is not a preference, it ships in whatever form the mode - # supports (no speculative opt-out knob). - anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) - if anchor: - md["thread_id"] = anchor + md = self._with_status_thread_anchor(chat_id, metadata) # Rich status parity: run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). # Carry it as the typing frame's content so the connector's Slack @@ -1143,18 +1182,10 @@ class RelayAdapter(BasePlatformAdapter): platform = self._platform_by_chat.get(str(chat_id)) if platform != Platform.SLACK.value: return - # Clear must target the SAME thread the heartbeat set: apply - # the identical synthetic-anchor rule as send_typing, or the clear - # frame no-ops threadless and the status line sticks until Slack's - # own timeout. - md = dict(metadata or {}) - if ( - not (md.get("thread_id") or md.get("thread_ts")) - and self._chat_type_by_chat.get(str(chat_id)) == "dm" - ): - anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) - if anchor: - md["thread_id"] = anchor + # Clear must target the SAME thread the heartbeat set, or the clear + # frame no-ops threadless and the status line sticks until Slack's own + # timeout. Shared helper with send_typing so the two guards cannot drift. + md = self._with_status_thread_anchor(chat_id, metadata) try: await self._transport.send_outbound( { @@ -1286,14 +1317,23 @@ class RelayAdapter(BasePlatformAdapter): if not uploaded: return None source_url = uploaded + # Same Slack thread-anchor contract as the text lane (send). Media + # frames egress through the connector's Slack sender too, so an + # unresolved anchor threads an image under the user's DM message in + # flat mode, and loses the per-message thread entirely in thread mode + # (threadTs() reads metadata only). Route through the shared helper. + media_metadata: Dict[str, Any] = dict(metadata or {}) + effective_reply_to = self._apply_slack_thread_anchor( + chat_id, reply_to, media_metadata + ) action: Dict[str, Any] = { "op": "send_media", "chat_id": chat_id, "media_kind": media_kind, "source_url": source_url, "content": caption or "", - "reply_to": reply_to, - "metadata": self._with_scope(chat_id, metadata), + "reply_to": effective_reply_to, + "metadata": self._with_scope(chat_id, media_metadata), } if filename: action["filename"] = filename diff --git a/tests/gateway/relay/test_relay_slack_dm_streaming.py b/tests/gateway/relay/test_relay_slack_dm_streaming.py index 2438609d89ca5..a812f358ec3b5 100644 --- a/tests/gateway/relay/test_relay_slack_dm_streaming.py +++ b/tests/gateway/relay/test_relay_slack_dm_streaming.py @@ -87,7 +87,7 @@ async def test_slack_dm_reply_keeps_anchor_in_thread_per_message_mode(): assert frame["reply_to"] == "1700.0001", ( "thread-per-message: the triggering ts anchors the final reply" ) - # QA-7: the connector's Slack sender threads on metadata.thread_id ONLY + # The connector's Slack sender threads on metadata.thread_id ONLY # (threadTs() never reads the frame's reply_to), so the surviving anchor # must be promoted into metadata for the send to actually thread. assert (frame["metadata"] or {}).get("thread_id") == "1700.0001" @@ -263,3 +263,99 @@ async def test_slack_dm_stream_consumer_threads_in_thread_per_message_mode(): assert consumer.message_id and consumer.message_id != "__no_edit__" edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"} assert edit_ids <= {stub.next_send_result["message_id"]} + + +# --------------------------------------------------------------------------- +# The media lane obeys the SAME thread-anchor contract as the text lane. +# +# send() and _send_media() both egress through the connector's Slack sender, +# so an anchor resolved on only one of them threads an image under the user's +# DM message in flat mode, and loses the per-message thread in thread mode +# (threadTs() reads metadata only). Both lanes route through +# _apply_slack_thread_anchor; these pin that they stay in agreement. +# --------------------------------------------------------------------------- +def _media_wire(chat_id: str, chat_type: str): + """Like _wire, but the descriptor advertises the send_media op.""" + desc = _slack_desc(supported_ops=("send", "edit", "typing", "send_media")) + stub = StubConnector(desc) + adapter = RelayAdapter(PlatformConfig(), desc, transport=stub) + src = SessionSource( + platform=Platform.SLACK, + chat_id=chat_id, + chat_type=chat_type, + user_id="U1", + ) + adapter._capture_scope( + MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + ) + return adapter, stub + + +@pytest.mark.asyncio +async def test_slack_dm_media_keeps_and_promotes_anchor_in_thread_mode(): + """Thread-per-message: an image must land in the per-message thread. The + connector threads on metadata.thread_id only, so the surviving anchor has + to be promoted there — a bare reply_to would post to the home channel.""" + adapter, stub = _media_wire("D1", "dm") + await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001") + frame = [f for f in stub.sent if f["op"] == "send_media"][-1] + assert frame["reply_to"] == "1700.0001" + assert (frame["metadata"] or {}).get("thread_id") == "1700.0001", ( + "media frame must carry the anchor where the connector reads it" + ) + + +@pytest.mark.asyncio +async def test_slack_dm_media_drops_synthetic_anchor_in_flat_mode(): + """Flat mode: the media frame drops the synthetic self-anchor exactly as + the text lane does, so the image posts flat at the DM root instead of + threading under the user's message, and invents no thread (#18859).""" + adapter, stub = _media_wire("D1", "dm") + adapter.config.extra = {"reply_in_thread": False} + await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001") + frame = [f for f in stub.sent if f["op"] == "send_media"][-1] + assert frame["reply_to"] is None + assert "thread_id" not in (frame["metadata"] or {}) + assert "thread_ts" not in (frame["metadata"] or {}) + + +@pytest.mark.asyncio +async def test_slack_channel_media_anchor_untouched(): + """Non-DM chats are outside the synthetic-anchor rule: a channel media + send keeps its reply_to unchanged.""" + adapter, stub = _media_wire("C1", "channel") + await adapter.send_image("C1", "https://example.com/x.png", reply_to="1700.0009") + frame = [f for f in stub.sent if f["op"] == "send_media"][-1] + assert frame["reply_to"] == "1700.0009" + + +@pytest.mark.asyncio +async def test_media_caller_metadata_not_mutated(): + """The anchor promotion must not leak into the caller's dict — media + helpers are called in loops with a shared metadata mapping.""" + adapter, stub = _media_wire("D1", "dm") + caller_md = {"user_id": "U1"} + await adapter.send_image( + "D1", "https://example.com/x.png", reply_to="1700.0001", metadata=caller_md + ) + assert caller_md == {"user_id": "U1"}, "caller metadata was mutated in place" + + +# --------------------------------------------------------------------------- +# The status clear targets the same thread the heartbeat set. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_typing_and_clear_share_one_status_anchor(): + """send_typing and stop_typing resolve the anchor through one helper: a + clear that no-ops threadless leaves the status line stuck until Slack's + own timeout.""" + adapter, stub = _wire("D1", "dm") + adapter._last_inbound_ts_by_chat["D1"] = "1700.0001" + await adapter.send_typing("D1") + await adapter.stop_typing("D1") + typing_frames = [f for f in stub.sent if f["op"] == "typing"] + assert len(typing_frames) == 2 + anchors = [(f["metadata"] or {}).get("thread_id") for f in typing_frames] + assert anchors == ["1700.0001", "1700.0001"], ( + "the clear must target the thread the heartbeat set" + ) From 0dc293f4f774a2a17d50b7bb0c389f2559d317d4 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 12:03:01 -0700 Subject: [PATCH 33/36] fix(relay): coerce Slack behavior flags exactly as the native adapter does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both relay Slack knobs read their value through bool(), while the native adapter they mirror uses str(raw).strip().lower() in {"1","true","yes","on"}. A YAML-quoted string diverges: dm_top_level_threads_as_sessions: "false" → relay True, native False Non-empty strings are truthy, so the escape hatch is silently ignored in exactly the shape an operator writes to switch it OFF. reply_in_thread has the same defect and gates reply placement, session keying and run.py's progress resolver, so one quoted "false" misfires three ways. Route both through a shared _coerce_flag mirroring native's predicate. Real booleans pass through untouched; None falls back to the default. Contract §8 documents the accepted spellings. Tests: both knobs parametrized over the true/false spellings native accepts, plus the absent-key default. --- docs/relay-connector-contract.md | 11 +++++ gateway/relay/adapter.py | 29 ++++++++++--- .../relay/test_relay_slack_dm_streaming.py | 42 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 91ae62b69e0ee..184ce905e6588 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -731,6 +731,10 @@ platforms: Resolution: nested `extra.` object wins → legacy flat key on `extra` honored as fallback → default. Source of truth: `RelayAdapter._effective_reply_in_thread` (`gateway/relay/adapter.py`). +Values coerce exactly as the native Slack adapter's do — `1/true/yes/on` +(case-insensitive, whitespace-trimmed) are ON, anything else is OFF — so a +YAML-quoted `"false"` turns a knob off rather than being read as a truthy +string. Current controls (Slack): @@ -745,6 +749,13 @@ thread-scoped, and in flat mode the send-side anchor strip guarantees the status anchor can never leak into reply placement. Semantics of the native key: see `website/docs/user-guide/messaging/slack.md`. +Thread-anchor resolution applies to EVERY send lane — text (`send`) and media +(`send_media`) alike — through one choke point +(`RelayAdapter._apply_slack_thread_anchor`). Media frames egress via the same +connector-side Slack sender, which threads on `metadata.thread_id` only, so an +attachment resolves its anchor identically to a text reply: promoted into +metadata in thread-per-message mode, stripped in flat mode. + Changes take effect on gateway restart; no connector involvement. --- diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 7c61ff4763d3b..f4170d1644441 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -344,10 +344,30 @@ class RelayAdapter(BasePlatformAdapter): sub = extra.get("slack") return sub if isinstance(sub, dict) else extra + @staticmethod + def _coerce_flag(raw: Any, default: bool) -> bool: + """Coerce an operator-supplied boolean exactly as native Slack does. + + Native SlackAdapter reads its behavior flags with + ``str(raw).strip().lower() in {"1","true","yes","on"}``, so a + YAML-quoted ``"false"`` — a shape operators write routinely — turns + the flag OFF. A bare ``bool()`` would read that same string as True + (non-empty string), silently ignoring the off switch. These knobs are + documented as native-parity mirrors, so they must coerce identically + or the parity claim only holds for unquoted YAML booleans. + """ + if raw is None: + return default + if isinstance(raw, bool): + return raw + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + def _effective_reply_in_thread(self) -> bool: """Resolve the thread-per-message vs flat-DM mode for fronted Slack.""" try: - return bool(self._relay_slack_extra().get("reply_in_thread", True)) + return self._coerce_flag( + self._relay_slack_extra().get("reply_in_thread"), True + ) except Exception: # noqa: BLE001 - config shape is operator-owned return True @@ -363,10 +383,9 @@ class RelayAdapter(BasePlatformAdapter): legacy steer/queue posture, decoupled from reply_in_thread. """ try: - return bool( - self._relay_slack_extra().get( - "dm_top_level_threads_as_sessions", True - ) + return self._coerce_flag( + self._relay_slack_extra().get("dm_top_level_threads_as_sessions"), + True, ) except Exception: # noqa: BLE001 - config shape is operator-owned return True diff --git a/tests/gateway/relay/test_relay_slack_dm_streaming.py b/tests/gateway/relay/test_relay_slack_dm_streaming.py index a812f358ec3b5..9969b739575a4 100644 --- a/tests/gateway/relay/test_relay_slack_dm_streaming.py +++ b/tests/gateway/relay/test_relay_slack_dm_streaming.py @@ -341,6 +341,48 @@ async def test_media_caller_metadata_not_mutated(): assert caller_md == {"user_id": "U1"}, "caller metadata was mutated in place" +# --------------------------------------------------------------------------- +# Operator flags coerce exactly as the native Slack adapter's do. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "raw,expected", + [ + (False, False), + ("false", False), + ("False", False), + (" no ", False), + ("off", False), + ("0", False), + (True, True), + ("true", True), + ("yes", True), + ("on", True), + ("1", True), + ], +) +def test_relay_slack_flags_coerce_like_native(raw, expected): + """A YAML-quoted "false" must turn these knobs OFF, matching native's + str().strip().lower() predicate. A bare bool() would read any non-empty + string as True and silently ignore the operator's off switch.""" + adapter, _stub = _wire("D1", "dm") + adapter.config.extra = { + "slack": { + "reply_in_thread": raw, + "dm_top_level_threads_as_sessions": raw, + } + } + assert adapter._effective_reply_in_thread() is expected + assert adapter._dm_top_level_threads_as_sessions() is expected + + +def test_relay_slack_flags_default_true_when_absent(): + """Both knobs default ON when the operator sets nothing.""" + adapter, _stub = _wire("D1", "dm") + adapter.config.extra = {} + assert adapter._effective_reply_in_thread() is True + assert adapter._dm_top_level_threads_as_sessions() is True + + # --------------------------------------------------------------------------- # The status clear targets the same thread the heartbeat set. # --------------------------------------------------------------------------- From b521fd9dc929e005343391b66025d69f085b8519 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 12:03:01 -0700 Subject: [PATCH 34/36] docs(relay): finish the QA-N scrub in the new relay test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier scrub covered adapter.py; nine internal QA-campaign tracker IDs remained in the two new test files, including a module docstring and an assertion message. They mean nothing to a future reader — describe the behavior instead. Comments only, no assertion changes. --- .../relay/test_relay_slack_prompt_dm_root.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index 56869f4c6fa35..466fd10071c2d 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -1,4 +1,4 @@ -"""Slack relay: interactive prompts follow the turn's thread stamp (QA-5). +"""Slack relay: interactive prompts follow the turn's thread stamp. The threading MODE (flat DM vs thread-per-message) is decided in exactly ONE place: run.py's ``_resolve_progress_thread_id``, which reads @@ -213,7 +213,7 @@ async def test_non_slack_dm_approval_keeps_thread_id(): # --------------------------------------------------------------------------- -# QA-1 rich status: the relay advertises Slack's text status line and carries +# Rich status: the relay advertises Slack's text status line and carries # the live per-tool phrase on the typing frame (native set_status_text parity). # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -251,7 +251,7 @@ async def test_typing_carries_live_status_phrase(): # --------------------------------------------------------------------------- -# QA-1 status thread anchor: typing frames synthesize the per-message thread +# Status thread anchor: typing frames synthesize the per-message thread # root in thread-per-message mode (the status line is thread-only on Slack). # --------------------------------------------------------------------------- def _wire_with_ts(chat_id, chat_type, message_id, **kw): @@ -282,7 +282,7 @@ async def test_typing_synthesizes_thread_anchor_in_thread_mode(): async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default(): """Flat-DM liveliness: the STATUS still anchors to the triggering ts (renders in the footer space, no message artifact) while replies stay - flat — QA-6/7 strip send anchors, so placement cannot inherit this.""" + flat — the send lane strips its anchors, so placement cannot inherit this.""" adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") adapter.config.extra = {"reply_in_thread": False} await adapter.send_typing("D1", metadata=None) @@ -294,7 +294,7 @@ async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default(): async def test_typing_anchors_unconditionally_in_both_modes(): """Liveliness is not a preference: the status anchors whenever an inbound ts exists, regardless of reply_in_thread. Placement safety comes from the - QA-6/7 send-side anchor strip, not from suppressing the status.""" + send-side anchor strip, not from suppressing the status.""" for extra in ({}, {"slack": {"reply_in_thread": False}}): adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") adapter.config.extra = extra @@ -306,7 +306,7 @@ async def test_typing_anchors_unconditionally_in_both_modes(): @pytest.mark.asyncio async def test_flat_mode_sends_stay_flat_with_status_anchor_active(): """The liveliness anchor must NOT leak into reply placement: sends in - flat mode still strip the synthetic anchor (QA-6/7 contract).""" + flat mode still strip the synthetic anchor (send-lane contract).""" adapter, stub = _wire_with_ts("D1", "dm", "1700.0042") adapter.config.extra = {"reply_in_thread": False} await adapter.send_typing("D1", metadata=None) @@ -339,7 +339,7 @@ async def test_stop_typing_clear_targets_same_synthesized_thread(): # --------------------------------------------------------------------------- -# QA-3 session keying: a top-level Slack DM message gets its own ts stamped as +# Session keying: a top-level Slack DM message gets its own ts stamped as # source.thread_id (native inbound parity) so each message keys a FRESH # session in thread-per-message mode; flat mode and real threads untouched. # --------------------------------------------------------------------------- @@ -370,7 +370,7 @@ def test_two_top_level_messages_key_distinct_sessions(): adapter._stamp_slack_session_thread(e2) k1 = build_session_key(e1.source) k2 = build_session_key(e2.source) - assert k1 != k2, "each top-level message must be its own session (QA-3)" + assert k1 != k2, "each top-level message must be its own session" def test_real_thread_reply_keeps_its_thread_session(): From ce9f6712ffafda485cdb2f00ac21fae3eaf40e53 Mon Sep 17 00:00:00 2001 From: Soju06 Date: Tue, 14 Jul 2026 05:30:59 +0000 Subject: [PATCH 35/36] refactor(agent): remove the inter-tool delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.0s sleep between sequential tool calls has been present since the initial commit with no documented rationale. It sleeps between local tool executions — the next LLM request goes out only after the whole batch — so it rate-limits nothing, and the parallel read-only path already runs with no delay. Every multi-tool turn pays (N-1) seconds of dead time. Remove the sleep, the internal tool_delay plumbing, and dead test assignments. AIAgent.__init__ keeps tool_delay as a deprecated no-op keyword for one release so existing programmatic callers construct cleanly; passing it emits a DeprecationWarning. --- agent/agent_init.py | 7 +--- agent/tool_executor.py | 3 -- run_agent.py | 13 +++++-- .../test_1630_context_overflow_loop.py | 1 - tests/run_agent/test_413_compression.py | 1 - .../test_conversation_fallback_state.py | 2 - .../test_malformed_tool_arguments.py | 1 - .../test_nonretryable_error_html_summary.py | 1 - .../test_partial_stream_finish_reason.py | 1 - tests/run_agent/test_run_agent.py | 38 +++++++++++++++++-- .../test_tool_call_guardrail_runtime.py | 1 - .../test_tool_call_incremental_persistence.py | 1 - .../test_turn_completion_explainer.py | 1 - 13 files changed, 47 insertions(+), 24 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index ea473632c6a51..d8eee9497ca3c 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -455,8 +455,7 @@ def init_agent( command: str = None, args: list[str] | None = None, model: str = "", - max_iterations: int = 500, # Default tool-calling iterations (shared with subagents) - tool_delay: float = 1.0, + max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, save_trajectories: bool = False, @@ -529,8 +528,7 @@ def init_agent( requested_provider (str): Original provider identity before runtime canonicalization api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") - max_iterations (int): Maximum number of tool calling iterations (default: 500) - tool_delay (float): Delay between tool calls in seconds (default: 1.0) + max_iterations (int): Maximum number of tool calling iterations (default: 90) enabled_toolsets (List[str]): Only enable tools from these toolsets (optional) disabled_toolsets (List[str]): Disable tools from these toolsets (optional) save_trajectories (bool): Whether to save conversation trajectories to JSONL files (default: False) @@ -576,7 +574,6 @@ def init_agent( # Shared iteration budget — parent creates, children inherit. # Consumed by every LLM turn across parent + all subagents. agent.iteration_budget = iteration_budget or IterationBudget(max_iterations) - agent.tool_delay = tool_delay agent.save_trajectories = save_trajectories agent.verbose_logging = verbose_logging agent.quiet_mode = quiet_mode diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 688d5720df2ef..f8bf5e6b2e548 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1979,9 +1979,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe return break - if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): - time.sleep(agent.tool_delay) - # ── Per-turn aggregate budget enforcement ───────────────────────── num_tools_seq = len(assistant_message.tool_calls) if finalize and num_tools_seq > 0: diff --git a/run_agent.py b/run_agent.py index 5d9af78815c6d..522579438d90a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -45,6 +45,7 @@ import tempfile import time import threading import uuid +import warnings from typing import List, Dict, Any, Optional, Callable # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the # SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object @@ -439,8 +440,8 @@ class AIAgent: command: str = None, args: list[str] | None = None, model: str = "", - max_iterations: int = 500, # Default tool-calling iterations (shared with subagents) - tool_delay: float = 1.0, + max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) + tool_delay: float = None, # Deprecated: accepted for compatibility, ignored enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, save_trajectories: bool = False, @@ -504,6 +505,13 @@ class AIAgent: requested_provider: str = None, ): """Forwarder — see ``agent.agent_init.init_agent``.""" + if tool_delay is not None: + warnings.warn( + "tool_delay is deprecated and ignored; sequential tool calls " + "no longer sleep between executions.", + DeprecationWarning, + stacklevel=2, + ) from agent.agent_init import init_agent init_agent( self, @@ -518,7 +526,6 @@ class AIAgent: args=args, model=model, max_iterations=max_iterations, - tool_delay=tool_delay, enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, save_trajectories=save_trajectories, diff --git a/tests/run_agent/test_1630_context_overflow_loop.py b/tests/run_agent/test_1630_context_overflow_loop.py index 3e5e3d0cfdfcc..ed0446ae56da6 100644 --- a/tests/run_agent/test_1630_context_overflow_loop.py +++ b/tests/run_agent/test_1630_context_overflow_loop.py @@ -38,7 +38,6 @@ class TestGeneric400Heuristic: a.client = MagicMock() a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False - a.tool_delay = 0 a.compression_enabled = False return a diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index cf05a9a905463..35cfefe22eefc 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -94,7 +94,6 @@ def agent(): a.client = MagicMock() a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False - a.tool_delay = 0 # Default matches production (`compression.enabled` defaults to True). # Overflow-recovery tests below verify that 413 / context-overflow # errors DO trigger compression; the disabled-path behavior is diff --git a/tests/run_agent/test_conversation_fallback_state.py b/tests/run_agent/test_conversation_fallback_state.py index c92cee2937c62..edea8af8872d3 100644 --- a/tests/run_agent/test_conversation_fallback_state.py +++ b/tests/run_agent/test_conversation_fallback_state.py @@ -80,7 +80,6 @@ def test_substantive_tool_only_turn_invalidates_older_housekeeping_fallback(): agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False agent.valid_tool_names = {"todo", "web_search"} @@ -147,7 +146,6 @@ def test_housekeeping_only_turn_still_sets_fallback(): agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False agent.valid_tool_names = {"memory"} diff --git a/tests/run_agent/test_malformed_tool_arguments.py b/tests/run_agent/test_malformed_tool_arguments.py index 6736182d30430..311556dc3a345 100644 --- a/tests/run_agent/test_malformed_tool_arguments.py +++ b/tests/run_agent/test_malformed_tool_arguments.py @@ -34,7 +34,6 @@ def _make_agent() -> AIAgent: skip_memory=True, ) agent.client = MagicMock() - agent.tool_delay = 0 agent._flush_messages_to_session_db = MagicMock() return agent diff --git a/tests/run_agent/test_nonretryable_error_html_summary.py b/tests/run_agent/test_nonretryable_error_html_summary.py index db765b124f301..7980547057f0f 100644 --- a/tests/run_agent/test_nonretryable_error_html_summary.py +++ b/tests/run_agent/test_nonretryable_error_html_summary.py @@ -72,7 +72,6 @@ def _make_agent() -> AIAgent: a.client = MagicMock() a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False - a.tool_delay = 0 a.compression_enabled = False a.save_trajectories = False return a diff --git a/tests/run_agent/test_partial_stream_finish_reason.py b/tests/run_agent/test_partial_stream_finish_reason.py index 05db5b7fcb30e..d9aead69164d9 100644 --- a/tests/run_agent/test_partial_stream_finish_reason.py +++ b/tests/run_agent/test_partial_stream_finish_reason.py @@ -333,7 +333,6 @@ def loop_agent(): a.client = MagicMock() a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False - a.tool_delay = 0 a.compression_enabled = False a.save_trajectories = False return a diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index c2b6db704eac3..e87c14a353694 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -978,6 +978,25 @@ class TestInit: assert agent.api_mode == "anthropic_messages" mock_anthropic.Anthropic.assert_called_once() + def test_tool_delay_kwarg_is_deprecated_noop(self): + """tool_delay stays accepted for compatibility but warns and is ignored.""" + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + with pytest.warns(DeprecationWarning, match="tool_delay"): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + tool_delay=0, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + # The value is discarded — nothing downstream reads it anymore. + assert not hasattr(a, "tool_delay") + def test_prompt_caching_claude_openrouter(self): """Claude model via OpenRouter should enable prompt caching.""" with ( @@ -2397,6 +2416,22 @@ class TestExecuteToolCalls: assert messages[0]["role"] == "tool" assert "search result" in messages[0]["content"] + def test_sequential_tool_calls_run_without_delay(self, agent): + """Two sequential tool calls execute back-to-back with no sleep between them.""" + tc1 = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tc2 = _mock_tool_call(name="web_search", arguments="{}", call_id="c2") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) + messages = [] + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch("agent.tool_executor.time.sleep") as mock_sleep, + ): + agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") + assert mock_hfc.call_count == 2 + mock_sleep.assert_not_called() + tool_results = [m for m in messages if m["role"] == "tool"] + assert [m["tool_call_id"] for m in tool_results] == ["c1", "c2"] + def test_sequential_memory_remove_notifies_provider_with_tool_result(self, agent): old_text = "stale preference entry" tc = _mock_tool_call( @@ -4419,7 +4454,6 @@ class TestRunConversation: """Common setup for run_conversation tests.""" agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False @@ -6702,7 +6736,6 @@ class TestRetryExhaustion: def _setup_agent(self, agent): agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False @@ -8703,7 +8736,6 @@ class TestReasoningReplayForStrictProviders: def _setup_agent(self, agent): agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False diff --git a/tests/run_agent/test_tool_call_guardrail_runtime.py b/tests/run_agent/test_tool_call_guardrail_runtime.py index 3bc8a83ab5927..95033831517de 100644 --- a/tests/run_agent/test_tool_call_guardrail_runtime.py +++ b/tests/run_agent/test_tool_call_guardrail_runtime.py @@ -54,7 +54,6 @@ def _make_agent(*tool_names: str, max_iterations: int = 10, config: dict | None agent.client = MagicMock() agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False return agent diff --git a/tests/run_agent/test_tool_call_incremental_persistence.py b/tests/run_agent/test_tool_call_incremental_persistence.py index f9ceafec06209..0cb4cfb916af3 100644 --- a/tests/run_agent/test_tool_call_incremental_persistence.py +++ b/tests/run_agent/test_tool_call_incremental_persistence.py @@ -74,7 +74,6 @@ def _make_agent(): agent.client = MagicMock() agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False return agent diff --git a/tests/run_agent/test_turn_completion_explainer.py b/tests/run_agent/test_turn_completion_explainer.py index 386a74c754c0a..8f366924b0681 100644 --- a/tests/run_agent/test_turn_completion_explainer.py +++ b/tests/run_agent/test_turn_completion_explainer.py @@ -52,7 +52,6 @@ def _make_agent(max_iterations: int = 10, config: dict | None = None) -> AIAgent agent.client = MagicMock() agent._cached_system_prompt = "You are helpful." agent._use_prompt_caching = False - agent.tool_delay = 0 agent.compression_enabled = False agent.save_trajectories = False # No fallback chain so empty responses exhaust deterministically. From 25927884e0c2df0d63ab59e0063254aa0d2fd494 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:14:27 -0700 Subject: [PATCH 36/36] docs(acp): document the Buzz Desktop model picker Buzz Desktop v0.5.1 now renders Hermes' ACP model menu in agent runtime settings. Add a short note under the Buzz Desktop host section explaining where the list comes from (the shared authenticated-provider inventory), the provider:model / custom:: ID shapes, and that a pick is session-scoped rather than a Hermes-wide default change. --- website/docs/user-guide/features/acp.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index a9012f065ae38..9562fba464e82 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -241,6 +241,19 @@ Recent installs write both `hermes` and `hermes-acp` launchers into older installs. As a manual fallback, configure Buzz's agent command as `hermes` with args `["acp"]`. +#### Model picker + +Buzz Desktop (v0.5.1+) renders Hermes' full model menu in the agent's runtime +settings. The list comes from Hermes itself over ACP: it shows every model +from providers you have authenticated in Hermes (the same inventory behind +`hermes model` and the `/model` command), so a model missing from the menu +means its provider has no credentials configured on the Hermes side. + +Entry IDs take the form `provider:model` (e.g. `openrouter:z-ai/glm-5.1`), or +`custom::` for custom OpenAI-compatible endpoints defined in +`config.yaml`. Picking a model applies to that agent's session; it does not +change your Hermes-wide default — use `hermes model` for that. + #### Keep Buzz agents owner-only Buzz creates every agent with **Who can talk to this agent** set to `Owner only`.