R7: pluggable SelectorPolicy seam + local-OSS (Ollama) confirmatory agent arm
Adds the RQ3 agent-selector backend as a reproducible, offline measurement decision — not relay/data-plane traffic. Containment is untouched: the model query is a local call to localhost:11434. - selector.py: SelectorPolicy ABC seam; static/random/agent(heuristic) built-ins; make_policy() resolves (strategy, agent_backend); run_selection writes a write-once selector.json provenance sidecar. - agent_selector.py: OllamaAgentPolicy — reproducible confirmatory arm (temp=0 + per-run seed, decisions cached keyed by (seed, state-hash), deterministic heuristic fallback on any model/parse failure, model weights digest pinned for the manifest). ClaudeExploratoryPolicy — EXPLORATORY-only stub: makes no paid call, refuses to serve as a confirmatory backend. - provenance.py: additive selector_backend field so an agent-selected run pins the exact model id + weights digest. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""R7 — Agent selector backends (RQ3 treatment arm).
|
||||
|
||||
Two backends plug into the :class:`~cmd_chat.sor.selector.SelectorPolicy` seam:
|
||||
|
||||
* :class:`OllamaAgentPolicy` — the **reproducible confirmatory** agent arm. It
|
||||
asks a *local, open-source* model (served by Ollama on localhost) to rank the
|
||||
live pool and pick a rebuild circuit. Reproducibility is engineered in, not
|
||||
hoped for:
|
||||
- inference is pinned (``temperature=0`` + per-run ``seed``);
|
||||
- every decision is **cached keyed by (seed, state-hash)**, so a replay is
|
||||
byte-identical regardless of any model nondeterminism;
|
||||
- any model/parse/validation failure falls back to the deterministic local
|
||||
heuristic, so a run never breaks and never silently drifts;
|
||||
- the model id + weights **digest** are recorded for the provenance manifest.
|
||||
This is a *local* call to ``localhost:11434`` — no external target, no relay
|
||||
traffic; it is a measurement decision, not part of the data plane.
|
||||
|
||||
* :class:`ClaudeExploratoryPolicy` — an **EXPLORATORY-only** hook for a Claude
|
||||
Code / frontier arm. It is deliberately inert: it makes **no paid model call**
|
||||
and refuses to run as a confirmatory backend (the paid frontier arm is human +
|
||||
budget gated, GOAL autonomy envelope (c)). It exists so the seam is documented,
|
||||
not so it can spend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cmd_chat.sor.selector import HeuristicAgentPolicy, SelectorContext, SelectorPolicy
|
||||
|
||||
DEFAULT_MODEL = "qwen2.5:3b"
|
||||
DEFAULT_HOST = "http://localhost:11434"
|
||||
|
||||
|
||||
def _state_hash(live: List[str], ctx: SelectorContext) -> str:
|
||||
"""A stable hash of the decision state — the same live pool + churn history +
|
||||
seed + hops always hashes identically, so it is a sound cache key."""
|
||||
blob = json.dumps(
|
||||
{
|
||||
"seed": ctx.seed,
|
||||
"hops": ctx.hops,
|
||||
"live": sorted(live),
|
||||
"kills": {n: ctx.kill_counts.get(n, 0) for n in sorted(live)},
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(blob).hexdigest()
|
||||
|
||||
|
||||
class OllamaAgentPolicy(SelectorPolicy):
|
||||
"""Local-OSS-model rebuild policy (reproducible confirmatory agent arm)."""
|
||||
|
||||
name = "agent-ollama"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = DEFAULT_MODEL,
|
||||
host: str = DEFAULT_HOST,
|
||||
timeout: float = 30.0,
|
||||
cache: Optional[Dict[str, List[str]]] = None,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.host = host.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._cache: Dict[str, List[str]] = cache if cache is not None else {}
|
||||
self._fallback = HeuristicAgentPolicy()
|
||||
self._model_digest: Optional[str] = None
|
||||
self._digest_resolved = False
|
||||
self._decisions: List[Dict[str, Any]] = []
|
||||
|
||||
# -- provenance ------------------------------------------------------- #
|
||||
def _resolve_digest(self) -> Optional[str]:
|
||||
"""Best-effort: fetch the model's content digest from Ollama so the exact
|
||||
weights are pinned into the run manifest. Never raises."""
|
||||
if self._digest_resolved:
|
||||
return self._model_digest
|
||||
self._digest_resolved = True
|
||||
try:
|
||||
req = urllib.request.Request(f"{self.host}/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
tags = json.loads(resp.read().decode("utf-8"))
|
||||
for m in tags.get("models", []):
|
||||
if m.get("name") == self.model or m.get("model") == self.model:
|
||||
self._model_digest = m.get("digest")
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
self._model_digest = None
|
||||
return self._model_digest
|
||||
|
||||
def provenance(self) -> Dict[str, Any]:
|
||||
n_model = sum(1 for d in self._decisions if d.get("source") == "model")
|
||||
n_cache = sum(1 for d in self._decisions if d.get("source") == "cache")
|
||||
n_fallback = sum(1 for d in self._decisions if d.get("source") == "fallback")
|
||||
return {
|
||||
"policy": self.name,
|
||||
"model": self.model,
|
||||
"model_digest": self._resolve_digest(),
|
||||
"host": self.host,
|
||||
"options": {"temperature": 0, "seed": "per-run", "format": "json"},
|
||||
"decisions": {
|
||||
"total": len(self._decisions),
|
||||
"model": n_model,
|
||||
"cache": n_cache,
|
||||
"fallback": n_fallback,
|
||||
},
|
||||
}
|
||||
|
||||
def decisions(self) -> List[Dict[str, Any]]:
|
||||
"""The per-state decision log (state-hash, chosen circuit, source) — folded
|
||||
into the run provenance sidecar so an agent run is fully auditable."""
|
||||
return list(self._decisions)
|
||||
|
||||
# -- decision --------------------------------------------------------- #
|
||||
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
|
||||
if len(live) < ctx.hops:
|
||||
return None
|
||||
key = f"{ctx.seed}:{_state_hash(live, ctx)}"
|
||||
if key in self._cache:
|
||||
chosen = list(self._cache[key])
|
||||
self._decisions.append({"state": key, "chosen": chosen, "source": "cache"})
|
||||
return chosen
|
||||
|
||||
chosen = self._query_model(live, ctx)
|
||||
source = "model"
|
||||
if not self._valid(chosen, live, ctx.hops):
|
||||
chosen = self._fallback.choose(live, ctx) # deterministic, never breaks
|
||||
source = "fallback"
|
||||
assert chosen is not None # pool>=hops guaranteed above
|
||||
self._cache[key] = list(chosen)
|
||||
self._decisions.append({"state": key, "chosen": list(chosen), "source": source})
|
||||
return list(chosen)
|
||||
|
||||
def _valid(self, chosen: Optional[List[str]], live: List[str], hops: int) -> bool:
|
||||
if not isinstance(chosen, list) or len(chosen) != hops:
|
||||
return False
|
||||
if len(set(chosen)) != hops:
|
||||
return False
|
||||
pool = set(live)
|
||||
return all(isinstance(c, str) and c in pool for c in chosen)
|
||||
|
||||
def _query_model(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
|
||||
"""Ask the local model for a rebuild circuit. Returns a list of node ids or
|
||||
``None`` on any transport/parse failure (caller falls back)."""
|
||||
prompt = self._build_prompt(live, ctx)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0, "seed": ctx.seed, "num_predict": 160},
|
||||
}
|
||||
).encode("utf-8")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.host}/api/generate", data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
text = body.get("response", "")
|
||||
parsed = json.loads(text)
|
||||
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
except Exception: # noqa: BLE001 — never let the model break a run
|
||||
return None
|
||||
# Accept {"circuit":[...]} or a bare [...].
|
||||
if isinstance(parsed, dict):
|
||||
parsed = parsed.get("circuit") or parsed.get("nodes")
|
||||
if not isinstance(parsed, list):
|
||||
return None
|
||||
return [str(x) for x in parsed][: ctx.hops]
|
||||
|
||||
def _build_prompt(self, live: List[str], ctx: SelectorContext) -> str:
|
||||
kills = {n: ctx.kill_counts.get(n, 0) for n in sorted(live)}
|
||||
return (
|
||||
"You are a path selector for a research relay measurement. Choose the "
|
||||
f"{ctx.hops} most churn-stable nodes to rebuild a circuit. A node that "
|
||||
"has been killed more often is less stable; prefer fewer past kills, "
|
||||
"break ties by node id ascending. Return ONLY strict JSON of the form "
|
||||
'{"circuit": ["<id>", ...]} with exactly '
|
||||
f"{ctx.hops} distinct ids chosen from the candidate pool.\n"
|
||||
f"Candidate pool (id: past_kills): {json.dumps(kills, sort_keys=True)}\n"
|
||||
)
|
||||
|
||||
|
||||
class ClaudeExploratoryPolicy(SelectorPolicy):
|
||||
"""EXPLORATORY-only Claude Code / frontier hook. Inert by design: it makes NO
|
||||
paid model call and cannot serve as a confirmatory arm. The paid frontier arm
|
||||
is human + budget gated (GOAL autonomy envelope (c)); wiring it live is a
|
||||
deliberate, separately-authorized step — not something this build performs."""
|
||||
|
||||
name = "agent-claude-exploratory"
|
||||
|
||||
def __init__(self, allow_exploratory: bool = False) -> None:
|
||||
self.allow_exploratory = allow_exploratory
|
||||
|
||||
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
|
||||
raise NotImplementedError(
|
||||
"Claude Code agent arm is EXPLORATORY-only and is not wired for "
|
||||
"confirmatory runs: no paid-model call is made in this build (GOAL "
|
||||
"envelope (c), human + budget gated). Use agent_backend='ollama' for "
|
||||
"the reproducible local-model arm."
|
||||
)
|
||||
|
||||
def provenance(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"policy": self.name,
|
||||
"status": "exploratory-stub",
|
||||
"paid_calls": False,
|
||||
"wired": False,
|
||||
}
|
||||
Reference in New Issue
Block a user