    p.addcdiv_(mhat, vhat.sqrt().add_(1e-8), value=-lr)

@torch.no_grad()
def _lion_single(p: torch.Tensor, g: torch.Tensor, st: Dict[str, torch.Tensor], lr: float, wd: float) -> None:
    m = st.setdefault("m", torch.zeros_like(p))
    u = 0.9 * m + 0.1 * g
    if wd:
        p.add_(p, alpha=-lr * wd)
    p.add_(u.sign(), alpha=-lr)
    m.mul_(0.99).add_(g, alpha=0.01)

@torch.no_grad()
def _sophia_single(p: torch.Tensor, g: torch.Tensor, st: Dict[str, torch.Tensor], lr: float, wd: float, h: torch.Tensor, eps: float = 1e-8) -> None:
    if wd:
        p.add_(p, alpha=-lr * wd)
    p.addcdiv_(g, h.abs().add_(eps), value=-lr)

def short_unroll_reinforce(
    base_model: torch.nn.Module,
    make_groups_fn: Callable[[torch.nn.Module], Any],
    gate: torch.nn.Module,
    batcher: Callable[[str, int], tuple],
    curv_beta: float = 0.99,
    base_lr: float = 3e-4,
    base_wd: float = 0.01,
    steps: int = 200,
    bsz: int = 128,
    device: str = "cuda",
    gate_opt_lr: float = 1e-3,
) -> Dict[str, float]:
    """
    Run a short shadow unroll and update the gate via REINFORCE using -val_loss as reward.
    """
    gate.train()
    gate_opt = torch.optim.AdamW(gate.parameters(), lr=gate_opt_lr)

    # 1) Clone model and groups
    model = clone_model_for_unroll(base_model).to(device)
    groups = make_groups_fn(model)

    # 2) Per-param state + curvature store (diagonal Fisher)
    stA: Dict[int, Dict[str, torch.Tensor]] = {}
    stL: Dict[int, Dict[str, torch.Tensor]] = {}
    stS: Dict[int, Dict[str, torch.Tensor]] = {}
    Hdiag: Dict[int, torch.Tensor] = {}

    # 3) Sample a discrete arm per group (fixed during this short unroll)
    logps = []
    arms = []
    with torch.no_grad():
        for gi, _ in enumerate(groups):
            feats = torch.zeros(1, 6, device=device)  # cold start features
            logits, lr_s, wd_s = gate(feats, torch.tensor([gi], device=device))
            pi = logits.softmax(-1)
            dist = torch.distributions.Categorical(pi.squeeze(0))
            a = dist.sample()
            logp = dist.log_prob(a)
            logps.append(logp)
            arms.append((int(a.item()), float(lr_s.item()), float(wd_s.item())))

    # 4) Unroll steps with chosen arms
    for _ in range(steps):
        xb, yb = batcher("train", bsz)
        logits, loss = model(xb, yb)
        for p in model.parameters():
            if p.grad is not None:
                p.grad.zero_()
        loss.backward()
        clip_grad_norm_(model.parameters(), 1.0)

        # curvature (Fisher diag)
        with torch.no_grad():
            for p in model.parameters():
                if p.grad is None:
                    continue
                pid = id(p)
                h = Hdiag.setdefault(pid, torch.zeros_like(p))
                g = p.grad
                h.mul_(curv_beta).addcmul_(g, g, value=(1 - curv_beta))

        # apply chosen arm per group
        with torch.no_grad():
            for gi, grp in enumerate(groups):
                arm, lr_s, wd_s = arms[gi]
                lr = base_lr * max(0.25, min(2.0, lr_s))
                wd = base_wd * max(0.0, min(2.0, wd_s))
                for p in grp["params"]:
                    if p.grad is None:
                        continue
                    g = p.grad
                    pid = id(p)
                    if arm == 0:
                        _adamw_single(p, g, stA.setdefault(pid, {}), lr, wd)
                    elif arm == 1:
                        _lion_single(p, g, stL.setdefault(pid, {}), lr, wd)
                    else:
                        _sophia_single(p, g, stS.setdefault(pid, {}), lr, wd, Hdiag.get(pid, torch.ones_like(p)))

    # 5) Meta-reward from validation loss
    with torch.no_grad():
        xb, yb = batcher("val", 512)
        _, vloss = model(xb, yb)
        reward = -vloss  # maximize negative val loss

    # 6) REINFORCE update
    adv = reward.detach() - vloss.detach()  # centered within run
    loss_meta = -(adv) * torch.stack(logps).mean()
    gate_opt.zero_grad()
    loss_meta.backward()
    gate_opt.step()

    return {"reward": float(reward), "val_loss": float(vloss)}


import logging
import json
import re
from typing import Any, Dict, List

try:
	import yaml  # type: ignore
except Exception:
	yaml = None

def parse_yaml_or_json(text: str) -> Dict[str, Any]:
	if yaml is not None:
		try:
			y = yaml.safe_load(text)
			if isinstance(y, dict):
				return y
		except Exception:
			pass
	text = text.strip()
	if text.startswith("{") and text.endswith("}"):
		try:
			return json.loads(text)
		except Exception:
			pass
	start = text.find("{")
	end = text.rfind("}")
	if start != -1 and end != -1 and end > start:
		try:
			return json.loads(text[start : end + 1])
		except Exception:
			pass
	return {}

def coerce_flat_yaml(pred_text: str) -> Dict[str, Any]:
	s = pred_text
	for k in ["tool:", "args:", "argv:", "cwd:"]:
		s = s.replace(k, f"\n{k}")
	s = s.replace(" - ", "\n  - ")
	parsed = parse_yaml_or_json(s)
	if isinstance(parsed, dict) and "tool" in parsed and isinstance(parsed.get("args"), dict):
		argv = parsed.get("args", {}).get("argv")
		if isinstance(argv, list):
			parsed["args"]["argv"] = [str(x) for x in argv]
		return parsed
	tool_match = re.search(r"tool:\s*([^\s\n]+)", pred_text)
	tool = tool_match.group(1).strip() if tool_match else ""
	argv: List[str] = re.findall(r"-\s*([^\s\n]+)", pred_text)
	cwd_match = re.search(r"cwd:\s*([^\s\n]+)", pred_text)
	cwd = cwd_match.group(1).strip() if cwd_match else ""
	if tool and argv and cwd:
		return {"tool": tool, "args": {"argv": argv, "cwd": cwd}}
	return {}

def parse_dom_pred_text(
	pred_text: str,
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Parse a DOM actuator prediction into a canonical action dict.
	Accepts YAML/JSON structures or a simple "<url> <selector>" format.
	Falls back to provided defaults when fields are missing.
	"""
	try:
		text = pred_text.strip()
		# Strip code fences if present
		if text.startswith("```") and text.endswith("```"):
			try:
				first_newline = text.find("\n")
				last_fence = text.rfind("```")
				if first_newline != -1 and last_fence != -1 and last_fence > first_newline:
					text = text[first_newline + 1:last_fence]
			except Exception:
				pass

		# 1) Try YAML/JSON first
		obj = parse_yaml_or_json(text)
		if isinstance(obj, dict):
			# Accept either {url, selector} or {tool, args: {url, selector}}
			url = None
			selector = None
			if "args" in obj and isinstance(obj.get("args"), dict):
				url = obj["args"].get("url")
				selector = obj["args"].get("selector")
			if url is None and "url" in obj:
				url = obj.get("url")
			if selector is None and "selector" in obj:
				selector = obj.get("selector")
			if isinstance(url, str) or isinstance(selector, str):
				return {
					"tool": str(obj.get("tool") or "browser.read"),
					"args": {
						"url": str(url or default_url or ""),
						"selector": str(selector or default_selector or ""),
					},
				}

		# 2) Fallback: simple whitespace split, first token is URL, rest is selector
		parts = [t for t in re.split(r"\s+", text) if t]
		if len(parts) >= 2:
			url = parts[0]
			selector = " ".join(parts[1:])
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}

		# 3) Fallback to defaults if available
		if default_url or default_selector:
			return {"tool": "browser.read", "args": {"url": default_url or "", "selector": default_selector or ""}}
	except Exception:
		pass
	return {}

def repair_cli_action(obs: Dict[str, Any], plan: Dict[str, Any], action: Dict[str, Any]) -> Dict[str, Any]:
	"""
	Heuristic repair for our known OS/CLI tasks to ensure argv completeness.
	"""
	try:
		if not isinstance(action, dict):
			return action
		tool = action.get("tool")
		args = action.setdefault("args", {}) if isinstance(action.get("args"), dict) else {"argv": [], "cwd": ""}
		action["args"] = args
		argv = args.get("argv")
		if not isinstance(argv, list):
			argv = []
		cwd = args.get("cwd") or (obs.get("meta") or {}).get("cwd") or ""
		content = (obs or {}).get("content", "")
		# Count file lines task
		if content.lower().startswith("count file lines"):
			args["argv"] = ["wc", "-l", "docs/a.txt"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		# Find ERROR lines task
		elif content.lower().startswith("find error lines"):
			args["argv"] = ["grep", "-n", "ERROR", "logs/app.log"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		else:
			# For other CLI tasks, keep as-is but ensure strings
			args["argv"] = [str(x) for x in argv]
			if cwd:
				args["cwd"] = cwd
		return action
	except Exception:
		return action

def repair_dom_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	action: Dict[str, Any],
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Heuristic repair for DOM actions to ensure tool/url/selector exist.
	Fills from observation meta first, then defaults.
	"""
	try:
		if not isinstance(action, dict):
			action = {}
		tool = action.get("tool") or "browser.read"
		args = action.get("args")
		if not isinstance(args, dict):
			args = {}
		meta = obs.get("meta", {}) if isinstance(obs, dict) else {}
		url = args.get("url") or meta.get("url") or default_url
		selector = args.get("selector") or meta.get("selector") or default_selector
		action["tool"] = tool
		action["args"] = {"url": url or "", "selector": selector or ""}
		return action
	except Exception:
		return action


import logging
import json
import re
from typing import Any, Dict, List

try:
	import yaml  # type: ignore
except Exception:
	yaml = None

def parse_yaml_or_json(text: str) -> Dict[str, Any]:
	if yaml is not None:
		try:
			y = yaml.safe_load(text)
			if isinstance(y, dict):
				return y
		except Exception:
			pass
	text = text.strip()
	if text.startswith("{") and text.endswith("}"):
		try:
			return json.loads(text)
		except Exception:
			pass
	start = text.find("{")
	end = text.rfind("}")
	if start != -1 and end != -1 and end > start:
		try:
			return json.loads(text[start : end + 1])
		except Exception:
			pass
	return {}

def coerce_flat_yaml(pred_text: str) -> Dict[str, Any]:
	s = pred_text
	for k in ["tool:", "args:", "argv:", "cwd:"]:
		s = s.replace(k, f"\n{k}")
	s = s.replace(" - ", "\n  - ")
	parsed = parse_yaml_or_json(s)
	if isinstance(parsed, dict) and "tool" in parsed and isinstance(parsed.get("args"), dict):
		argv = parsed.get("args", {}).get("argv")
		if isinstance(argv, list):
			parsed["args"]["argv"] = [str(x) for x in argv]
		return parsed
	tool_match = re.search(r"tool:\s*([^\s\n]+)", pred_text)
	tool = tool_match.group(1).strip() if tool_match else ""
	argv: List[str] = re.findall(r"-\s*([^\s\n]+)", pred_text)
	cwd_match = re.search(r"cwd:\s*([^\s\n]+)", pred_text)
	cwd = cwd_match.group(1).strip() if cwd_match else ""
	if tool and argv and cwd:
		return {"tool": tool, "args": {"argv": argv, "cwd": cwd}}


			if isinstance(y, dict):
				return y
		except Exception:
			pass
	text = text.strip()
	if text.startswith("{") and text.endswith("}"):
		try:
			return json.loads(text)
		except Exception:
			pass
	start = text.find("{")
	end = text.rfind("}")
	if start != -1 and end != -1 and end > start:
		try:
			return json.loads(text[start : end + 1])
		except Exception:
			pass
	return {}

def coerce_flat_yaml(pred_text: str) -> Dict[str, Any]:
	s = pred_text
	for k in ["tool:", "args:", "argv:", "cwd:"]:
		s = s.replace(k, f"\n{k}")
	s = s.replace(" - ", "\n  - ")
	parsed = parse_yaml_or_json(s)
	if isinstance(parsed, dict) and "tool" in parsed and isinstance(parsed.get("args"), dict):
		argv = parsed.get("args", {}).get("argv")
		if isinstance(argv, list):
			parsed["args"]["argv"] = [str(x) for x in argv]
		return parsed
	tool_match = re.search(r"tool:\s*([^\s\n]+)", pred_text)
	tool = tool_match.group(1).strip() if tool_match else ""
	argv: List[str] = re.findall(r"-\s*([^\s\n]+)", pred_text)
	cwd_match = re.search(r"cwd:\s*([^\s\n]+)", pred_text)
	cwd = cwd_match.group(1).strip() if cwd_match else ""
	if tool and argv and cwd:
		return {"tool": tool, "args": {"argv": argv, "cwd": cwd}}
	return {}

def parse_dom_pred_text(
	pred_text: str,
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Parse a DOM actuator prediction into a canonical action dict.
	Accepts YAML/JSON structures or a simple "<url> <selector>" format.
	Falls back to provided defaults when fields are missing.
	"""
	try:
		text = pred_text.strip()
		# Strip code fences if present
		if text.startswith("```") and text.endswith("```"):
			try:
				first_newline = text.find("\n")
				last_fence = text.rfind("```")
				if first_newline != -1 and last_fence != -1 and last_fence > first_newline:


	s = pred_text
	for k in ["tool:", "args:", "argv:", "cwd:"]:
		s = s.replace(k, f"\n{k}")
	s = s.replace(" - ", "\n  - ")
	parsed = parse_yaml_or_json(s)
	if isinstance(parsed, dict) and "tool" in parsed and isinstance(parsed.get("args"), dict):
		argv = parsed.get("args", {}).get("argv")
		if isinstance(argv, list):
			parsed["args"]["argv"] = [str(x) for x in argv]
		return parsed
	tool_match = re.search(r"tool:\s*([^\s\n]+)", pred_text)
	tool = tool_match.group(1).strip() if tool_match else ""
	argv: List[str] = re.findall(r"-\s*([^\s\n]+)", pred_text)
	cwd_match = re.search(r"cwd:\s*([^\s\n]+)", pred_text)
	cwd = cwd_match.group(1).strip() if cwd_match else ""
	if tool and argv and cwd:
		return {"tool": tool, "args": {"argv": argv, "cwd": cwd}}
	return {}

def parse_dom_pred_text(
	pred_text: str,
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Parse a DOM actuator prediction into a canonical action dict.
	Accepts YAML/JSON structures or a simple "<url> <selector>" format.
	Falls back to provided defaults when fields are missing.
	"""
	try:
		text = pred_text.strip()
		# Strip code fences if present
		if text.startswith("```") and text.endswith("```"):
			try:
				first_newline = text.find("\n")
				last_fence = text.rfind("```")
				if first_newline != -1 and last_fence != -1 and last_fence > first_newline:
					text = text[first_newline + 1:last_fence]
			except Exception:
				pass

		# 1) Try YAML/JSON first
		obj = parse_yaml_or_json(text)
		if isinstance(obj, dict):
			# Accept either {url, selector} or {tool, args: {url, selector}}
			url = None
			selector = None
			if "args" in obj and isinstance(obj.get("args"), dict):
				url = obj["args"].get("url")
				selector = obj["args"].get("selector")
			if url is None and "url" in obj:
				url = obj.get("url")
			if selector is None and "selector" in obj:
				selector = obj.get("selector")
			if isinstance(url, str) or isinstance(selector, str):
				return {
					"tool": str(obj.get("tool") or "browser.read"),
					"args": {
						"url": str(url or default_url or ""),
						"selector": str(selector or default_selector or ""),
					},
				}

		# 2) Fallback: simple whitespace split, first token is URL, rest is selector
		parts = [t for t in re.split(r"\s+", text) if t]
		if len(parts) >= 2:
			url = parts[0]
			selector = " ".join(parts[1:])
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}

		# 3) Fallback to defaults if available
		if default_url or default_selector:
			return {"tool": "browser.read", "args": {"url": default_url or "", "selector": default_selector or ""}}
	except Exception:
		pass
	return {}

def repair_cli_action(obs: Dict[str, Any], plan: Dict[str, Any], action: Dict[str, Any]) -> Dict[str, Any]:
	"""
	Heuristic repair for our known OS/CLI tasks to ensure argv completeness.
	"""
	try:
		if not isinstance(action, dict):
			return action
		tool = action.get("tool")
		args = action.setdefault("args", {}) if isinstance(action.get("args"), dict) else {"argv": [], "cwd": ""}
		action["args"] = args
		argv = args.get("argv")
		if not isinstance(argv, list):
			argv = []
		cwd = args.get("cwd") or (obs.get("meta") or {}).get("cwd") or ""
		content = (obs or {}).get("content", "")
		# Count file lines task
		if content.lower().startswith("count file lines"):
			args["argv"] = ["wc", "-l", "docs/a.txt"]


						"url": str(url or default_url or ""),
						"selector": str(selector or default_selector or ""),
					},
				}

		# 2) Fallback: simple whitespace split, first token is URL, rest is selector
		parts = [t for t in re.split(r"\s+", text) if t]
		if len(parts) >= 2:
			url = parts[0]
			selector = " ".join(parts[1:])
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}

		# 3) Fallback to defaults if available
		if default_url or default_selector:
			return {"tool": "browser.read", "args": {"url": default_url or "", "selector": default_selector or ""}}
	except Exception:
		pass
	return {}

def repair_cli_action(obs: Dict[str, Any], plan: Dict[str, Any], action: Dict[str, Any]) -> Dict[str, Any]:
	"""
	Heuristic repair for our known OS/CLI tasks to ensure argv completeness.
	"""
	try:
		if not isinstance(action, dict):
			return action
		tool = action.get("tool")
		args = action.setdefault("args", {}) if isinstance(action.get("args"), dict) else {"argv": [], "cwd": ""}
		action["args"] = args
		argv = args.get("argv")
		if not isinstance(argv, list):
			argv = []
		cwd = args.get("cwd") or (obs.get("meta") or {}).get("cwd") or ""
		content = (obs or {}).get("content", "")
		# Count file lines task
		if content.lower().startswith("count file lines"):
			args["argv"] = ["wc", "-l", "docs/a.txt"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		# Find ERROR lines task
		elif content.lower().startswith("find error lines"):
			args["argv"] = ["grep", "-n", "ERROR", "logs/app.log"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		else:
			# For other CLI tasks, keep as-is but ensure strings
			args["argv"] = [str(x) for x in argv]
			if cwd:
				args["cwd"] = cwd
		return action
	except Exception:
		return action

def repair_dom_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	action: Dict[str, Any],
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Heuristic repair for DOM actions to ensure tool/url/selector exist.
	Fills from observation meta first, then defaults.
	"""
	try:
		if not isinstance(action, dict):
			action = {}
		tool = action.get("tool") or "browser.read"
		args = action.get("args")
		if not isinstance(args, dict):
			args = {}


			args["argv"] = ["wc", "-l", "docs/a.txt"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		# Find ERROR lines task
		elif content.lower().startswith("find error lines"):
			args["argv"] = ["grep", "-n", "ERROR", "logs/app.log"]
			if cwd:
				args["cwd"] = cwd
			action["tool"] = tool or "cli.run"
		else:
			# For other CLI tasks, keep as-is but ensure strings
			args["argv"] = [str(x) for x in argv]
			if cwd:
				args["cwd"] = cwd
		return action
	except Exception:
		return action

def repair_dom_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	action: Dict[str, Any],
	default_url: str = "",
	default_selector: str = "",
) -> Dict[str, Any]:
	"""
	Heuristic repair for DOM actions to ensure tool/url/selector exist.
	Fills from observation meta first, then defaults.
	"""
	try:
		if not isinstance(action, dict):
			action = {}
		tool = action.get("tool") or "browser.read"
		args = action.get("args")
		if not isinstance(args, dict):
			args = {}
		meta = obs.get("meta", {}) if isinstance(obs, dict) else {}
		url = args.get("url") or meta.get("url") or default_url
		selector = args.get("selector") or meta.get("selector") or default_selector
		action["tool"] = tool
		action["args"] = {"url": url or "", "selector": selector or ""}
		return action
	except Exception:
		return action


from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple

from agi_dw.core.ops.tracing import trace_span  # type: ignore
from agi_dw.core.actuator.t5_actuator import ActuatorT5Predictor
from agi_dw.core.actuator.il_baseline import ActuatorILNearestNeighbor
from agi_dw.core.actuator.template_actuator import TemplateActuator
from agi_dw.core.actuator.two_head import (
	TwoHeadActuator,
	HeuristicToolClassifier,
	HeuristicSlotFiller,
	default_cli_templates,
)
from agi_dw.core.actuator.router import extract_router_features, get_task_success_threshold  # type: ignore
from agi_dw.core.actuator.parse import repair_cli_action, repair_dom_action

@dataclass
class ActuatorConfig:
	mode: str  # "template" | "two_head" | "router" | "t5" | "nn"
	t5_model: Optional[str] = None
	il_path: Optional[str] = None
	learned_router: bool = False
	router_model_path: Optional[str] = None
	router_threshold: float = 0.5
	router_use_packed_threshold: bool = False  # CLI-specific behavior
	router_thresholds_json: Optional[str] = None  # CLI per-task override
	dom_structured: bool = False  # for DOM T5

@dataclass
class RouterVerifierConfig:
	model: Optional[str] = None
	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:
	try:
		p = Path(path or "")
		return p.exists() and any(p.iterdir())
	except Exception:
		return False

def select_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	cfg: ActuatorConfig,
	extra: RouterExtras,
	verifier_cfg: Optional[RouterVerifierConfig] = None,
	wm_prior_cfg: Optional[WMPriorConfig] = None,
	wm_screen_cfg: Optional[WMScreenConfig] = None,
	repair_cfg: Optional[RepairConfig] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
	"""Return (action, router_decision?) preserving loop-visible fields and semantics."""
	mode = (cfg.mode or "router").lower()
	act: Optional[Dict[str, Any]] = None
	router_decision: Optional[Dict[str, Any]] = None

	with trace_span("act", {"actuator": mode}):
		# Pre-compute verifier risk for router if requested
		pre_verifier_risk: Optional[float] = extra.pre_verifier_risk
		if mode == "router" and verifier_cfg and verifier_cfg.model and pre_verifier_risk is None:
			try:
				from agi_dw.core.verifier.service import VerifierServiceConfig, quick_risk  # type: ignore
				vsc = VerifierServiceConfig(
					model=verifier_cfg.model,
					backend=verifier_cfg.backend,
					adapter_dir=verifier_cfg.adapter_dir,
					adapter_bank=verifier_cfg.adapter_bank,
					structured_mode=verifier_cfg.structured_mode,
					timeout_sec=max(2, int(verifier_cfg.timeout_sec)),
					strict=False,
					calibrate=False,
				)
				pre_verifier_risk = float(quick_risk(obs, plan, vsc))
			except Exception:
				pre_verifier_risk = None

		# Optional WM prior for router features
		wm_prior: Optional[Dict[str, float]] = extra.wm_prior
		if wm_prior is None and wm_prior_cfg and wm_prior_cfg.enabled and wm_prior_cfg.model_path:
			try:
				from agi_dw.core.world_model.api import WorldModelPrior  # type: ignore
				model_path = Path(wm_prior_cfg.model_path)
				if model_path.exists():
					wm = WorldModelPrior.load(model_path)
					wm_prior = wm.predict_prior(obs, plan, action={})
			except Exception:
				wm_prior = None
		if mode == "template" and extra.domain == "cli":
			# CLI template-first cascade
			action = TemplateActuator().predict_action(obs, plan)
			if action:
				act = {"tool": action.tool, "args": action.args}
			else:
				th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
				th_act = th.predict_action(obs, plan)
				if th_act:
					act = {"tool": th_act.tool, "args": th_act.args}
				if not act:
					if _t5_available(cfg.t5_model):
						act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
					if not act and cfg.il_path:
						act = ActuatorILNearestNeighbor(cfg.il_path).predict_action(obs, plan)
			return act or {}, None
		if mode == "two_head" and extra.domain == "cli":
			th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
			th_act = th.predict_action(obs, plan)
			act = {"tool": th_act.tool, "args": th_act.args} if th_act else None
			if not act and _t5_available(cfg.t5_model):
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			if not act and cfg.il_path:
				act = ActuatorILNearestNeighbor(cfg.il_path).predict_action(obs, plan)
			return act or {}, None
		if mode == "t5":
			if extra.domain == "dom":
				act = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
			else:
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			return act or {}, None
		if mode == "nn":
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			return act or {}, None

		# Router mode (both CLI and DOM)
		# Build features with optional extras for learned models
		features = extract_router_features(obs, plan, extras={"wm_prior": wm_prior, "verifier_risk": pre_verifier_risk})
		router_decision = {"picked": None, "reason": None, "features": features}
		# CLI heuristic shortcut: prefer TwoHead when confident
		if extra.domain == "cli":
			try:
				th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
				th_act = th.predict_action(obs, plan)
				if th_act:
					act = {"tool": th_act.tool, "args": th_act.args}
					router_decision.update({"picked": "two_head", "reason": "heuristic_tool_match"})
					return act, router_decision
			except Exception:
				pass
		# DOM confidence fields derived from features if available
		if extra.domain == "dom":
			dom_confidence = {
				"selector_entropy": features.get("dom_selector_entropy", 0.0),
				"selector_malformed": features.get("dom_selector_malformed", 0.0),
				"verifier_risk": features.get("dom_verifier_risk", extra.pre_verifier_risk if isinstance(extra.pre_verifier_risk, (int, float)) else 0.5),
			}
			router_decision["dom_confidence"] = dom_confidence

		picked = "t5"
		router_prob: Optional[float] = None
		threshold_used: Optional[float] = None
		if cfg.learned_router:
			try:
				from joblib import load as joblib_load  # type: ignore
				pack = joblib_load(Path(cfg.router_model_path or ""))
				clf = pack.get("clf")
				keys = pack.get("keys") or []
				packed_thr = pack.get("threshold")
				packed_thrs = pack.get("thresholds") or {}
				# Vectorize features in the saved key order
				vec = [float(features.get(k, 0.0)) for k in keys]
				import numpy as np  # type: ignore
				p = clf.predict_proba(np.asarray([vec]))[0][1] if hasattr(clf, "predict_proba") else 0.5
				router_prob = float(p)
				# Entropy bits (CLI loop used this)
				try:
					import math as _m
					ent = float(- (p * _m.log2(max(p, 1e-9)) + (1 - p) * _m.log2(max(1 - p, 1e-9))))
					router_decision["entropy_bits"] = ent
				except Exception:
					pass
				# Threshold selection
				thr = float(cfg.router_threshold)
				# Prefer per-task threshold from packed model when available
				try:
					thr = float(get_task_success_threshold(pack, extra.task_name or ""))
				except Exception:
					# Fallbacks when helper is unavailable
					thr = float(packed_thr) if (cfg.router_use_packed_threshold and packed_thr is not None) else float(cfg.router_threshold)
				# Optional per-task overrides from JSON (primarily CLI)
				try:
					if cfg.router_thresholds_json:
						pth = Path(cfg.router_thresholds_json)
						if pth.exists():
							import json as _json  # type: ignore
							obj = _json.loads(pth.read_text(encoding="utf-8"))
							if isinstance(obj, dict) and extra.task_name and extra.task_name in obj:
								thr = float(obj.get(extra.task_name))
				except Exception:
					pass
				picked = "t5" if p >= thr else "nn"
				threshold_used = thr
			except Exception:
				picked = "t5"

		# Ensure T5 availability; fallback to NN if missing
		if picked == "t5" and not _t5_available(cfg.t5_model):
			picked = "nn"

		if picked == "t5":
			if extra.domain == "dom":
				act = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
			else:
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			reason = "learned_router" if cfg.learned_router else "default_t5"
			router_decision.update({"picked": "t5", "reason": reason, "prob": router_prob, "threshold": threshold_used if cfg.learned_router else None})
		else:
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			router_decision.update({"picked": "nn", "reason": "learned_router", "prob": router_prob, "threshold": threshold_used if cfg.learned_router else None})
		if not act:
			# Final fallback to NN if T5 failed
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			router_decision.update({"picked": "nn", "reason": "t5_failed"})
		# Optional WM screen between picked and alternative actuator
		if (wm_screen_cfg and wm_screen_cfg.enabled) and (wm_prior_cfg and wm_prior_cfg.enabled and wm_prior_cfg.model_path):
			try:
				from agi_dw.core.world_model.api import WorldModelPrior  # type: ignore
				wm_path = Path(wm_prior_cfg.model_path)
				if wm_path.exists():
					wm = WorldModelPrior.load(wm_path)
					prior_main = wm.predict_prior(obs, plan, act or {}) or {"risk": 0.5}
					risk_main = float(prior_main.get("risk", 0.5))
					alt_action: Optional[Dict[str, Any]] = None
					if mode == "router":
						# Flip between t5 and nn
						if router_decision and router_decision.get("picked") == "t5":
							alt_action = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
						else:
							if extra.domain == "dom":
								alt_action = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
							else:
								alt_action = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
					else:
						# If not router, compare with an alternative
						if mode in ("template", "two_head", "nn"):
							if extra.domain == "dom":
								alt_action = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
							else:
								alt_action = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
						else:
							alt_action = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
					prior_alt = wm.predict_prior(obs, plan, alt_action or {}) or {"risk": 0.5}
					risk_alt = float(prior_alt.get("risk", 0.5))
					if risk_main >= float(wm_screen_cfg.threshold) and (risk_alt < risk_main):
						act = alt_action or act
						if isinstance(act, dict):
							act["wm_screen"] = {
								"risk_main": risk_main,
								"risk_alt": risk_alt,
							}
			except Exception:
				pass

		# Optional repair
		if repair_cfg and isinstance(act, dict):
			if repair_cfg.domain == "cli":
				act = repair_cli_action(obs, plan, act or {})
			elif repair_cfg.domain == "dom":
				# Enforce stay_on_url if present
				try:
					stay_url = str((plan.get("constraints") or {}).get("stay_on_url", "")) if isinstance(plan, dict) else ""
					if stay_url:
						args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
						args_d["url"] = stay_url
						args_d["selector"] = args_d.get("selector", repair_cfg.default_selector or "")
						act["args"] = args_d
				except Exception:
					pass
				# Prefer obs args if requested
				if repair_cfg.prefer_obs_args:
					meta = obs.get("meta", {}) if isinstance(obs, dict) else {}
					obs_url = meta.get("url", repair_cfg.default_url)
					obs_sel = meta.get("selector", repair_cfg.default_selector)
					args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
					if obs_url is not None:
						args_d["url"] = obs_url
					if obs_sel is not None:
						args_d["selector"] = obs_sel
					act["args"] = args_d
				# Structural repair
				act = repair_dom_action(obs, plan, act or {}, default_url=repair_cfg.default_url or "", default_selector=repair_cfg.default_selector or "")

		return act or {}, router_decision

def compute_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	"""Centralized wrapper to compute router features for training/datasets."""
	try:
		return extract_router_features(obs, plan, extras=extras or {})
	except Exception:
		return {}

def apply_code_patch(
	repo_dir: str,
	diff_text: str,
	branch_name: Optional[str] = None,
	*,
	strict: Optional[bool] = None,
	max_files: int = 10,
	max_added: int = 400,
	max_deleted: int = 200,
	allow_paths: Optional[list[str]] = None,
	block_paths: Optional[list[str]] = None,
) -> Dict[str, Any]:
	"""Apply a code patch using PatchActuator with centralized safety budgets.

	Returns the PatchActuator result dict.
	"""
	if not isinstance(diff_text, str) or not diff_text.strip():
		return {"status": "error", "error": "empty diff"}
	# Enforce safety env variables (shared policy with dev loop)
	try:
		import os as _os  # type: ignore
		if strict is not None:
			_os.environ["AGI_PATCH_STRICT"] = "1" if bool(strict) else "0"
		else:
			_os.environ.setdefault("AGI_PATCH_STRICT", "1")
		_os.environ.setdefault("AGI_PATCH_MAX_FILES", str(int(max_files)))
		_os.environ.setdefault("AGI_PATCH_MAX_ADDED", str(int(max_added)))
		_os.environ.setdefault("AGI_PATCH_MAX_DELETED", str(int(max_deleted)))
		_os.environ.setdefault("AGI_PATCH_ALLOW_FILE_MODES", "0")
		_os.environ.setdefault("AGI_PATCH_ALLOW_RENAMES", "0")
		# Optional allow/block path patterns (comma-separated globs)
		ap = allow_paths or []
		bp = block_paths or []
		if isinstance(ap, str):
			ap = [ap]
		if isinstance(bp, str):
			bp = [bp]
		allow_val = ",".join(sorted({str(p).strip() for p in (ap or []) if str(p).strip()}))
		block_val = ",".join(sorted({str(p).strip() for p in (bp or []) if str(p).strip()}))
		if allow_val:
			_os.environ["AGI_PATCH_ALLOW"] = allow_val
		if block_val:
			_os.environ["AGI_PATCH_BLOCK"] = block_val
	except Exception:
		pass
	# Apply via PatchActuator
	try:
		from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
		pa = PatchActuator()
		return pa.apply_patch(diff_text, str(repo_dir), branch_name=branch_name)
	except Exception as e:  # pragma: no cover
		return {"status": "error", "error": f"patch-apply-failed: {e}"}


ActuatorConfig

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple

from agi_dw.core.ops.tracing import trace_span  # type: ignore
from agi_dw.core.actuator.t5_actuator import ActuatorT5Predictor
from agi_dw.core.actuator.il_baseline import ActuatorILNearestNeighbor
from agi_dw.core.actuator.template_actuator import TemplateActuator
from agi_dw.core.actuator.two_head import (
	TwoHeadActuator,
	HeuristicToolClassifier,
	HeuristicSlotFiller,
	default_cli_templates,
)
from agi_dw.core.actuator.router import extract_router_features, get_task_success_threshold  # type: ignore
from agi_dw.core.actuator.parse import repair_cli_action, repair_dom_action

@dataclass
class ActuatorConfig:
	mode: str  # "template" | "two_head" | "router" | "t5" | "nn"
	t5_model: Optional[str] = None
	il_path: Optional[str] = None
	learned_router: bool = False
	router_model_path: Optional[str] = None
	router_threshold: float = 0.5
	router_use_packed_threshold: bool = False  # CLI-specific behavior
	router_thresholds_json: Optional[str] = None  # CLI per-task override
	dom_structured: bool = False  # for DOM T5

@dataclass
class RouterVerifierConfig:
	model: Optional[str] = None
	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:


RouterVerifierConfig

	default_cli_templates,
)
from agi_dw.core.actuator.router import extract_router_features, get_task_success_threshold  # type: ignore
from agi_dw.core.actuator.parse import repair_cli_action, repair_dom_action

@dataclass
class ActuatorConfig:
	mode: str  # "template" | "two_head" | "router" | "t5" | "nn"
	t5_model: Optional[str] = None
	il_path: Optional[str] = None
	learned_router: bool = False
	router_model_path: Optional[str] = None
	router_threshold: float = 0.5
	router_use_packed_threshold: bool = False  # CLI-specific behavior
	router_thresholds_json: Optional[str] = None  # CLI per-task override
	dom_structured: bool = False  # for DOM T5

@dataclass
class RouterVerifierConfig:
	model: Optional[str] = None
	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI


WMPriorConfig

	il_path: Optional[str] = None
	learned_router: bool = False
	router_model_path: Optional[str] = None
	router_threshold: float = 0.5
	router_use_packed_threshold: bool = False  # CLI-specific behavior
	router_thresholds_json: Optional[str] = None  # CLI per-task override
	dom_structured: bool = False  # for DOM T5

@dataclass
class RouterVerifierConfig:
	model: Optional[str] = None
	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"


WMScreenConfig

	dom_structured: bool = False  # for DOM T5

@dataclass
class RouterVerifierConfig:
	model: Optional[str] = None
	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:


RouterExtras

	backend: str = "hf"
	adapter_dir: Optional[str] = None
	adapter_bank: Optional[str] = None
	structured_mode: str = "json"
	timeout_sec: int = 30

@dataclass
class WMPriorConfig:
	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:
	try:
		p = Path(path or "")
		return p.exists() and any(p.iterdir())
	except Exception:
		return False

def select_action(
	obs: Dict[str, Any],


RepairConfig

	enabled: bool = False
	model_path: Optional[str] = None

@dataclass
class WMScreenConfig:
	enabled: bool = False
	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:
	try:
		p = Path(path or "")
		return p.exists() and any(p.iterdir())
	except Exception:
		return False

def select_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	cfg: ActuatorConfig,
	extra: RouterExtras,
	verifier_cfg: Optional[RouterVerifierConfig] = None,
	wm_prior_cfg: Optional[WMPriorConfig] = None,
	wm_screen_cfg: Optional[WMScreenConfig] = None,
	repair_cfg: Optional[RepairConfig] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:


	threshold: float = 0.7

@dataclass
class RouterExtras:
	domain: str  # "cli" | "dom"
	wm_prior: Optional[Dict[str, float]] = None
	pre_verifier_risk: Optional[float] = None
	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:
	try:
		p = Path(path or "")
		return p.exists() and any(p.iterdir())
	except Exception:
		return False

def select_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	cfg: ActuatorConfig,
	extra: RouterExtras,
	verifier_cfg: Optional[RouterVerifierConfig] = None,
	wm_prior_cfg: Optional[WMPriorConfig] = None,
	wm_screen_cfg: Optional[WMScreenConfig] = None,
	repair_cfg: Optional[RepairConfig] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
	"""Return (action, router_decision?) preserving loop-visible fields and semantics."""
	mode = (cfg.mode or "router").lower()
	act: Optional[Dict[str, Any]] = None
	router_decision: Optional[Dict[str, Any]] = None

	with trace_span("act", {"actuator": mode}):
		# Pre-compute verifier risk for router if requested
		pre_verifier_risk: Optional[float] = extra.pre_verifier_risk


	task_name: Optional[str] = None  # CLI
	log_router: bool = False

@dataclass
class RepairConfig:
	domain: str  # "cli" | "dom"
	prefer_obs_args: bool = False
	default_url: Optional[str] = None  # DOM
	default_selector: Optional[str] = None  # DOM

def _t5_available(path: Optional[str]) -> bool:
	try:
		p = Path(path or "")
		return p.exists() and any(p.iterdir())
	except Exception:
		return False

def select_action(
	obs: Dict[str, Any],
	plan: Dict[str, Any],
	cfg: ActuatorConfig,
	extra: RouterExtras,
	verifier_cfg: Optional[RouterVerifierConfig] = None,
	wm_prior_cfg: Optional[WMPriorConfig] = None,
	wm_screen_cfg: Optional[WMScreenConfig] = None,
	repair_cfg: Optional[RepairConfig] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
	"""Return (action, router_decision?) preserving loop-visible fields and semantics."""
	mode = (cfg.mode or "router").lower()
	act: Optional[Dict[str, Any]] = None
	router_decision: Optional[Dict[str, Any]] = None

	with trace_span("act", {"actuator": mode}):
		# Pre-compute verifier risk for router if requested
		pre_verifier_risk: Optional[float] = extra.pre_verifier_risk
		if mode == "router" and verifier_cfg and verifier_cfg.model and pre_verifier_risk is None:
			try:
				from agi_dw.core.verifier.service import VerifierServiceConfig, quick_risk  # type: ignore
				vsc = VerifierServiceConfig(
					model=verifier_cfg.model,
					backend=verifier_cfg.backend,
					adapter_dir=verifier_cfg.adapter_dir,
					adapter_bank=verifier_cfg.adapter_bank,
					structured_mode=verifier_cfg.structured_mode,
					timeout_sec=max(2, int(verifier_cfg.timeout_sec)),
					strict=False,
					calibrate=False,
				)
				pre_verifier_risk = float(quick_risk(obs, plan, vsc))
			except Exception:
				pre_verifier_risk = None

		# Optional WM prior for router features
		wm_prior: Optional[Dict[str, float]] = extra.wm_prior
		if wm_prior is None and wm_prior_cfg and wm_prior_cfg.enabled and wm_prior_cfg.model_path:
			try:
				from agi_dw.core.world_model.api import WorldModelPrior  # type: ignore
				model_path = Path(wm_prior_cfg.model_path)
				if model_path.exists():
					wm = WorldModelPrior.load(model_path)
					wm_prior = wm.predict_prior(obs, plan, action={})
			except Exception:
				wm_prior = None
		if mode == "template" and extra.domain == "cli":
			# CLI template-first cascade
			action = TemplateActuator().predict_action(obs, plan)
			if action:
				act = {"tool": action.tool, "args": action.args}
			else:
				th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
				th_act = th.predict_action(obs, plan)
				if th_act:
					act = {"tool": th_act.tool, "args": th_act.args}
				if not act:
					if _t5_available(cfg.t5_model):
						act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
					if not act and cfg.il_path:
						act = ActuatorILNearestNeighbor(cfg.il_path).predict_action(obs, plan)
			return act or {}, None
		if mode == "two_head" and extra.domain == "cli":
			th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
			th_act = th.predict_action(obs, plan)
			act = {"tool": th_act.tool, "args": th_act.args} if th_act else None
			if not act and _t5_available(cfg.t5_model):
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			if not act and cfg.il_path:
				act = ActuatorILNearestNeighbor(cfg.il_path).predict_action(obs, plan)
			return act or {}, None
		if mode == "t5":
			if extra.domain == "dom":
				act = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
			else:
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			return act or {}, None
		if mode == "nn":
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			return act or {}, None

		# Router mode (both CLI and DOM)
		# Build features with optional extras for learned models
		features = extract_router_features(obs, plan, extras={"wm_prior": wm_prior, "verifier_risk": pre_verifier_risk})
		router_decision = {"picked": None, "reason": None, "features": features}
		# CLI heuristic shortcut: prefer TwoHead when confident
		if extra.domain == "cli":
			try:
				th = TwoHeadActuator(HeuristicToolClassifier(), HeuristicSlotFiller(), default_cli_templates())
				th_act = th.predict_action(obs, plan)
				if th_act:
					act = {"tool": th_act.tool, "args": th_act.args}
					router_decision.update({"picked": "two_head", "reason": "heuristic_tool_match"})
					return act, router_decision
			except Exception:
				pass
		# DOM confidence fields derived from features if available
		if extra.domain == "dom":
			dom_confidence = {
				"selector_entropy": features.get("dom_selector_entropy", 0.0),
				"selector_malformed": features.get("dom_selector_malformed", 0.0),
				"verifier_risk": features.get("dom_verifier_risk", extra.pre_verifier_risk if isinstance(extra.pre_verifier_risk, (int, float)) else 0.5),
			}
			router_decision["dom_confidence"] = dom_confidence

		picked = "t5"
		router_prob: Optional[float] = None
		threshold_used: Optional[float] = None
		if cfg.learned_router:
			try:
				from joblib import load as joblib_load  # type: ignore
				pack = joblib_load(Path(cfg.router_model_path or ""))
				clf = pack.get("clf")
				keys = pack.get("keys") or []
				packed_thr = pack.get("threshold")
				packed_thrs = pack.get("thresholds") or {}
				# Vectorize features in the saved key order
				vec = [float(features.get(k, 0.0)) for k in keys]
				import numpy as np  # type: ignore
				p = clf.predict_proba(np.asarray([vec]))[0][1] if hasattr(clf, "predict_proba") else 0.5
				router_prob = float(p)
				# Entropy bits (CLI loop used this)
				try:
					import math as _m
					ent = float(- (p * _m.log2(max(p, 1e-9)) + (1 - p) * _m.log2(max(1 - p, 1e-9))))
					router_decision["entropy_bits"] = ent
				except Exception:
					pass
				# Threshold selection
				thr = float(cfg.router_threshold)
				# Prefer per-task threshold from packed model when available
				try:
					thr = float(get_task_success_threshold(pack, extra.task_name or ""))
				except Exception:
					# Fallbacks when helper is unavailable
					thr = float(packed_thr) if (cfg.router_use_packed_threshold and packed_thr is not None) else float(cfg.router_threshold)
				# Optional per-task overrides from JSON (primarily CLI)
				try:
					if cfg.router_thresholds_json:
						pth = Path(cfg.router_thresholds_json)
						if pth.exists():
							import json as _json  # type: ignore
							obj = _json.loads(pth.read_text(encoding="utf-8"))
							if isinstance(obj, dict) and extra.task_name and extra.task_name in obj:
								thr = float(obj.get(extra.task_name))
				except Exception:
					pass
				picked = "t5" if p >= thr else "nn"
				threshold_used = thr
			except Exception:
				picked = "t5"

		# Ensure T5 availability; fallback to NN if missing
		if picked == "t5" and not _t5_available(cfg.t5_model):
			picked = "nn"

		if picked == "t5":
			if extra.domain == "dom":
				act = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
			else:
				act = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
			reason = "learned_router" if cfg.learned_router else "default_t5"
			router_decision.update({"picked": "t5", "reason": reason, "prob": router_prob, "threshold": threshold_used if cfg.learned_router else None})
		else:
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			router_decision.update({"picked": "nn", "reason": "learned_router", "prob": router_prob, "threshold": threshold_used if cfg.learned_router else None})
		if not act:
			# Final fallback to NN if T5 failed
			act = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
			router_decision.update({"picked": "nn", "reason": "t5_failed"})
		# Optional WM screen between picked and alternative actuator
		if (wm_screen_cfg and wm_screen_cfg.enabled) and (wm_prior_cfg and wm_prior_cfg.enabled and wm_prior_cfg.model_path):
			try:
				from agi_dw.core.world_model.api import WorldModelPrior  # type: ignore
				wm_path = Path(wm_prior_cfg.model_path)
				if wm_path.exists():
					wm = WorldModelPrior.load(wm_path)
					prior_main = wm.predict_prior(obs, plan, act or {}) or {"risk": 0.5}
					risk_main = float(prior_main.get("risk", 0.5))
					alt_action: Optional[Dict[str, Any]] = None
					if mode == "router":
						# Flip between t5 and nn
						if router_decision and router_decision.get("picked") == "t5":
							alt_action = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
						else:
							if extra.domain == "dom":
								alt_action = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
							else:
								alt_action = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
					else:
						# If not router, compare with an alternative
						if mode in ("template", "two_head", "nn"):
							if extra.domain == "dom":
								alt_action = ActuatorT5Predictor(cfg.t5_model, mode="dom", structured=bool(cfg.dom_structured)).predict_action(obs, plan)
							else:
								alt_action = ActuatorT5Predictor(cfg.t5_model).predict_action(obs, plan)
						else:
							alt_action = ActuatorILNearestNeighbor(cfg.il_path or "").predict_action(obs, plan)
					prior_alt = wm.predict_prior(obs, plan, alt_action or {}) or {"risk": 0.5}
					risk_alt = float(prior_alt.get("risk", 0.5))
					if risk_main >= float(wm_screen_cfg.threshold) and (risk_alt < risk_main):
						act = alt_action or act
						if isinstance(act, dict):
							act["wm_screen"] = {
								"risk_main": risk_main,
								"risk_alt": risk_alt,
							}
			except Exception:
				pass

		# Optional repair
		if repair_cfg and isinstance(act, dict):
			if repair_cfg.domain == "cli":
				act = repair_cli_action(obs, plan, act or {})
			elif repair_cfg.domain == "dom":
				# Enforce stay_on_url if present
				try:
					stay_url = str((plan.get("constraints") or {}).get("stay_on_url", "")) if isinstance(plan, dict) else ""
					if stay_url:
						args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
						args_d["url"] = stay_url
						args_d["selector"] = args_d.get("selector", repair_cfg.default_selector or "")
						act["args"] = args_d
				except Exception:
					pass
				# Prefer obs args if requested
				if repair_cfg.prefer_obs_args:
					meta = obs.get("meta", {}) if isinstance(obs, dict) else {}
					obs_url = meta.get("url", repair_cfg.default_url)
					obs_sel = meta.get("selector", repair_cfg.default_selector)
					args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
					if obs_url is not None:
						args_d["url"] = obs_url
					if obs_sel is not None:
						args_d["selector"] = obs_sel
					act["args"] = args_d
				# Structural repair
				act = repair_dom_action(obs, plan, act or {}, default_url=repair_cfg.default_url or "", default_selector=repair_cfg.default_selector or "")

		return act or {}, router_decision

def compute_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	"""Centralized wrapper to compute router features for training/datasets."""
	try:
		return extract_router_features(obs, plan, extras=extras or {})
	except Exception:
		return {}

def apply_code_patch(
	repo_dir: str,
	diff_text: str,
	branch_name: Optional[str] = None,
	*,
	strict: Optional[bool] = None,
	max_files: int = 10,
	max_added: int = 400,
	max_deleted: int = 200,
	allow_paths: Optional[list[str]] = None,


						act["args"] = args_d
				except Exception:
					pass
				# Prefer obs args if requested
				if repair_cfg.prefer_obs_args:
					meta = obs.get("meta", {}) if isinstance(obs, dict) else {}
					obs_url = meta.get("url", repair_cfg.default_url)
					obs_sel = meta.get("selector", repair_cfg.default_selector)
					args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
					if obs_url is not None:
						args_d["url"] = obs_url
					if obs_sel is not None:
						args_d["selector"] = obs_sel
					act["args"] = args_d
				# Structural repair
				act = repair_dom_action(obs, plan, act or {}, default_url=repair_cfg.default_url or "", default_selector=repair_cfg.default_selector or "")

		return act or {}, router_decision

def compute_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	"""Centralized wrapper to compute router features for training/datasets."""
	try:
		return extract_router_features(obs, plan, extras=extras or {})
	except Exception:
		return {}

def apply_code_patch(
	repo_dir: str,
	diff_text: str,
	branch_name: Optional[str] = None,
	*,
	strict: Optional[bool] = None,
	max_files: int = 10,
	max_added: int = 400,
	max_deleted: int = 200,
	allow_paths: Optional[list[str]] = None,
	block_paths: Optional[list[str]] = None,
) -> Dict[str, Any]:
	"""Apply a code patch using PatchActuator with centralized safety budgets.

	Returns the PatchActuator result dict.
	"""
	if not isinstance(diff_text, str) or not diff_text.strip():
		return {"status": "error", "error": "empty diff"}


					args_d = act.get("args") if isinstance(act.get("args"), dict) else {}
					if obs_url is not None:
						args_d["url"] = obs_url
					if obs_sel is not None:
						args_d["selector"] = obs_sel
					act["args"] = args_d
				# Structural repair
				act = repair_dom_action(obs, plan, act or {}, default_url=repair_cfg.default_url or "", default_selector=repair_cfg.default_selector or "")

		return act or {}, router_decision

def compute_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	"""Centralized wrapper to compute router features for training/datasets."""
	try:
		return extract_router_features(obs, plan, extras=extras or {})
	except Exception:
		return {}

def apply_code_patch(
	repo_dir: str,
	diff_text: str,
	branch_name: Optional[str] = None,
	*,
	strict: Optional[bool] = None,
	max_files: int = 10,
	max_added: int = 400,
	max_deleted: int = 200,
	allow_paths: Optional[list[str]] = None,
	block_paths: Optional[list[str]] = None,
) -> Dict[str, Any]:
	"""Apply a code patch using PatchActuator with centralized safety budgets.

	Returns the PatchActuator result dict.
	"""
	if not isinstance(diff_text, str) or not diff_text.strip():
		return {"status": "error", "error": "empty diff"}
	# Enforce safety env variables (shared policy with dev loop)
	try:
		import os as _os  # type: ignore
		if strict is not None:
			_os.environ["AGI_PATCH_STRICT"] = "1" if bool(strict) else "0"
		else:
			_os.environ.setdefault("AGI_PATCH_STRICT", "1")
		_os.environ.setdefault("AGI_PATCH_MAX_FILES", str(int(max_files)))
		_os.environ.setdefault("AGI_PATCH_MAX_ADDED", str(int(max_added)))
		_os.environ.setdefault("AGI_PATCH_MAX_DELETED", str(int(max_deleted)))
		_os.environ.setdefault("AGI_PATCH_ALLOW_FILE_MODES", "0")
		_os.environ.setdefault("AGI_PATCH_ALLOW_RENAMES", "0")
		# Optional allow/block path patterns (comma-separated globs)
		ap = allow_paths or []
		bp = block_paths or []
		if isinstance(ap, str):
			ap = [ap]
		if isinstance(bp, str):
			bp = [bp]
		allow_val = ",".join(sorted({str(p).strip() for p in (ap or []) if str(p).strip()}))
		block_val = ",".join(sorted({str(p).strip() for p in (bp or []) if str(p).strip()}))
		if allow_val:
			_os.environ["AGI_PATCH_ALLOW"] = allow_val
		if block_val:
			_os.environ["AGI_PATCH_BLOCK"] = block_val
	except Exception:
		pass
	# Apply via PatchActuator
	try:
		from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
		pa = PatchActuator()
		return pa.apply_patch(diff_text, str(repo_dir), branch_name=branch_name)
	except Exception as e:  # pragma: no cover
		return {"status": "error", "error": f"patch-apply-failed: {e}"}


import logging
import json
from typing import Any, Dict

class TemplateActuator:
	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any]) -> Dict[str, Any]:
		kind = (obs or {}).get("kind")
		content = ((obs or {}).get("content") or "").lower()
		cwd = ((obs or {}).get("meta") or {}).get("cwd", "")
		if kind == "cli":
			if content.startswith("count file lines"):
				return {"tool": "cli.run", "args": {"argv": ["wc", "-l", "docs/a.txt"], "cwd": cwd}}
			if content.startswith("count words"):
				return {"tool": "cli.run", "args": {"argv": ["wc", "-w", "docs/a.txt"], "cwd": cwd}}
			if content.startswith("count chars"):
				return {"tool": "cli.run", "args": {"argv": ["wc", "-m", "docs/a.txt"], "cwd": cwd}}
			if content.startswith("grep -i info"):
				return {"tool": "cli.run", "args": {"argv": ["grep", "-i", "info", "logs/app.log"], "cwd": cwd}}
			if content.startswith("grep error"):
				return {"tool": "cli.run", "args": {"argv": ["grep", "-n", "ERROR", "logs/app.log"], "cwd": cwd}}
			if content.startswith("count info lines"):
				return {"tool": "cli.run", "args": {"argv": ["grep", "-c", "INFO", "logs/app.log"], "cwd": cwd}}
			if content.startswith("head 2 lines"):
				return {"tool": "cli.run", "args": {"argv": ["head", "-n", "2", "logs/app.log"], "cwd": cwd}}
			if content.startswith("tail last line"):
				return {"tool": "cli.run", "args": {"argv": ["tail", "-n", "1", "logs/app.log"], "cwd": cwd}}
		# Default: return empty to trigger validator/repair
		return {}


import logging
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml

class ActuatorILNearestNeighbor:
	def __init__(self, dataset_path: str) -> None:
		self.examples: List[Tuple[str, str]] = []  # (input_text, output_text)
		p = Path(dataset_path)
		if not p.exists():
			raise FileNotFoundError(f"IL dataset not found: {dataset_path}")
		for line in p.read_text(encoding="utf-8").splitlines():
			if not line.strip():
				continue
			obj = json.loads(line)
			self.examples.append((obj["input"], obj["output"]))
		if not self.examples:
			raise ValueError("IL dataset is empty")

	@staticmethod
	def _similarity(a: str, b: str) -> float:
		# Simple Jaccard over tokens as a baseline
		set_a = set(a.split())
		set_b = set(b.split())
		if not set_a and not set_b:
			return 1.0
		inter = len(set_a & set_b)
		union = len(set_a | set_b)
		return inter / union if union else 0.0

	def predict(self, input_text: str) -> str:
		best_sim = -1.0
		best_out = "{}"
		for ex_in, ex_out in self.examples:
			sim = self._similarity(input_text, ex_in)
			if sim > best_sim:
				best_sim = sim
				best_out = ex_out
		return best_out

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any]) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		out_text = self.predict(input_text)
		try:
			parsed = parse_yaml_or_json(out_text)
			if not parsed:
				parsed = coerce_flat_yaml(out_text)
			return parsed if isinstance(parsed, dict) else {}
		except Exception:
			return {}


import logging
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml

class ActuatorILNearestNeighbor:
	def __init__(self, dataset_path: str) -> None:
		self.examples: List[Tuple[str, str]] = []  # (input_text, output_text)
		p = Path(dataset_path)
		if not p.exists():
			raise FileNotFoundError(f"IL dataset not found: {dataset_path}")
		for line in p.read_text(encoding="utf-8").splitlines():
			if not line.strip():
				continue
			obj = json.loads(line)
			self.examples.append((obj["input"], obj["output"]))
		if not self.examples:
			raise ValueError("IL dataset is empty")

	@staticmethod
	def _similarity(a: str, b: str) -> float:
		# Simple Jaccard over tokens as a baseline
		set_a = set(a.split())
		set_b = set(b.split())
		if not set_a and not set_b:
			return 1.0
		inter = len(set_a & set_b)
		union = len(set_a | set_b)
		return inter / union if union else 0.0

	def predict(self, input_text: str) -> str:
		best_sim = -1.0
		best_out = "{}"
		for ex_in, ex_out in self.examples:
			sim = self._similarity(input_text, ex_in)
			if sim > best_sim:
				best_sim = sim
				best_out = ex_out


from typing import Any, Dict, List, Tuple

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml

class ActuatorILNearestNeighbor:
	def __init__(self, dataset_path: str) -> None:
		self.examples: List[Tuple[str, str]] = []  # (input_text, output_text)
		p = Path(dataset_path)
		if not p.exists():
			raise FileNotFoundError(f"IL dataset not found: {dataset_path}")
		for line in p.read_text(encoding="utf-8").splitlines():
			if not line.strip():
				continue
			obj = json.loads(line)
			self.examples.append((obj["input"], obj["output"]))
		if not self.examples:
			raise ValueError("IL dataset is empty")

	@staticmethod
	def _similarity(a: str, b: str) -> float:
		# Simple Jaccard over tokens as a baseline
		set_a = set(a.split())
		set_b = set(b.split())
		if not set_a and not set_b:
			return 1.0
		inter = len(set_a & set_b)
		union = len(set_a | set_b)
		return inter / union if union else 0.0

	def predict(self, input_text: str) -> str:
		best_sim = -1.0
		best_out = "{}"
		for ex_in, ex_out in self.examples:
			sim = self._similarity(input_text, ex_in)
			if sim > best_sim:
				best_sim = sim
				best_out = ex_out
		return best_out

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any]) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		out_text = self.predict(input_text)
		try:
			parsed = parse_yaml_or_json(out_text)
			if not parsed:
				parsed = coerce_flat_yaml(out_text)
			return parsed if isinstance(parsed, dict) else {}
		except Exception:


predict

			raise FileNotFoundError(f"IL dataset not found: {dataset_path}")
		for line in p.read_text(encoding="utf-8").splitlines():
			if not line.strip():
				continue
			obj = json.loads(line)
			self.examples.append((obj["input"], obj["output"]))
		if not self.examples:
			raise ValueError("IL dataset is empty")

	@staticmethod
	def _similarity(a: str, b: str) -> float:
		# Simple Jaccard over tokens as a baseline
		set_a = set(a.split())
		set_b = set(b.split())
		if not set_a and not set_b:
			return 1.0
		inter = len(set_a & set_b)
		union = len(set_a | set_b)
		return inter / union if union else 0.0

	def predict(self, input_text: str) -> str:
		best_sim = -1.0
		best_out = "{}"
		for ex_in, ex_out in self.examples:
			sim = self._similarity(input_text, ex_in)
			if sim > best_sim:
				best_sim = sim
				best_out = ex_out
		return best_out

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any]) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		out_text = self.predict(input_text)
		try:
			parsed = parse_yaml_or_json(out_text)
			if not parsed:
				parsed = coerce_flat_yaml(out_text)
			return parsed if isinstance(parsed, dict) else {}
		except Exception:
			return {}


	def _similarity(a: str, b: str) -> float:
		# Simple Jaccard over tokens as a baseline
		set_a = set(a.split())
		set_b = set(b.split())
		if not set_a and not set_b:
			return 1.0
		inter = len(set_a & set_b)
		union = len(set_a | set_b)
		return inter / union if union else 0.0

	def predict(self, input_text: str) -> str:
		best_sim = -1.0
		best_out = "{}"
		for ex_in, ex_out in self.examples:
			sim = self._similarity(input_text, ex_in)
			if sim > best_sim:
				best_sim = sim
				best_out = ex_out
		return best_out

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any]) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		out_text = self.predict(input_text)
		try:
			parsed = parse_yaml_or_json(out_text)
			if not parsed:
				parsed = coerce_flat_yaml(out_text)
			return parsed if isinstance(parsed, dict) else {}
		except Exception:
			return {}


import logging
from typing import List, Set, Dict, Optional
from transformers import LogitsProcessor
import torch
import re

class JsonSchemaLogitsProcessor(LogitsProcessor):
	"""
	Minimal schema-aware constrained decoding for JSON actions.
	Phase-1 structure:
	  {"tool": "<wc|grep|head|tail>", "args": {"argv": ["<string>", ...], "cwd": "<string>"}}

	Tiny JSON FSM + token masking.
	Allows only:
	  - structural tokens: { } [ ] : , "
	  - keys: "tool","args","argv","cwd"
	  - tool strings: "wc","grep","head","tail"
	  - common flags: "-l","-w","-m","-n","-c","-i"
	  - any quoted strings inside argv/cwd (we only gate the start of strings)
	Extend tools/flags via constructor sets.
	"""

	def __init__(self, tokenizer, tools: Set[str], flags: Set[str]):
		self.tok = tokenizer
		self.tools = set(tools)
		self.flags = set(flags)

		# Cache vocab + ids for common symbols
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		# Include code-domain args keys to support native code actions
		self.key_ids = self._ids_for_strings(['tool', 'args', 'argv', 'cwd', 'diff', 'branch_name', 'paths', 'timeout'])
		self.tool_ids = self._ids_for_strings(sorted(self.tools))
		self.flag_ids = self._ids_for_strings(sorted(self.flags))
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			# Gate the first token of the quoted string to bias toward the lexeme
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		"""Very coarse state machine via regex on partial JSON text."""
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"cwd"\s*:\s*$', t):
			return 'EXPECT_CWD_STRING'
		if re.search(r'"argv"\s*:\s*$', t):
			return 'EXPECT_ARGV_ARRAY'
		if t.endswith('['):
			return 'IN_ARGV_ARRAY_START'
		if re.search(r'\[\s*"', t):
			return 'IN_ARGV_STRING'
		if re.search(r'\[\s*".*"\s*,\s*$', t):
			return 'IN_ARGV_NEXT'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			# Unexpected rank; fall back to no-op
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		s = self._state(t)
		allow: Set[int] = set()

		q = self.quote_id
		sym = self.sym

		# If we are currently inside an open quoted string, do not constrain token choices.
		# Count unescaped quotes; if odd, we're likely inside a string.
		# This keeps constraints active only at string boundaries and structure.
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores

		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':
			allow |= (self.tool_ids or set())
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGS_OBJ':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s == 'EXPECT_CWD_STRING':
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGV_ARRAY':
			if sym['['] is not None:
				allow.add(sym['['])
		elif s == 'IN_ARGV_ARRAY_START':
			if sym[']'] is not None:
				allow.add(sym[']'])
			if q is not None:
				allow.add(q)
		elif s in ('IN_ARGV_STRING', 'IN_ARGV_NEXT'):
			allow |= (self.flag_ids or set())
			if q is not None:
				allow.add(q)
			if sym[']'] is not None:
				allow.add(sym[']'])
			if sym[','] is not None:
				allow.add(sym[','])
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', '[', ']', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])

		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores

class DomJsonLogitsProcessor(LogitsProcessor):
	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		# If inside a quoted string, do not constrain
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores
		s = self._state(t)
		allow: Set[int] = set()
		q = self.quote_id
		sym = self.sym
		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':
			allow |= (self.tool_ids or set())
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGS_OBJ':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_URL_STRING', 'EXPECT_SELECTOR_STRING'):
			if q is not None:
				allow.add(q)
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])
		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores


JsonSchemaLogitsProcessor

import logging
from typing import List, Set, Dict, Optional
from transformers import LogitsProcessor
import torch
import re

class JsonSchemaLogitsProcessor(LogitsProcessor):
	"""
	Minimal schema-aware constrained decoding for JSON actions.
	Phase-1 structure:
	  {"tool": "<wc|grep|head|tail>", "args": {"argv": ["<string>", ...], "cwd": "<string>"}}

	Tiny JSON FSM + token masking.
	Allows only:
	  - structural tokens: { } [ ] : , "
	  - keys: "tool","args","argv","cwd"
	  - tool strings: "wc","grep","head","tail"
	  - common flags: "-l","-w","-m","-n","-c","-i"
	  - any quoted strings inside argv/cwd (we only gate the start of strings)
	Extend tools/flags via constructor sets.
	"""

	def __init__(self, tokenizer, tools: Set[str], flags: Set[str]):
		self.tok = tokenizer
		self.tools = set(tools)
		self.flags = set(flags)

		# Cache vocab + ids for common symbols
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		# Include code-domain args keys to support native code actions
		self.key_ids = self._ids_for_strings(['tool', 'args', 'argv', 'cwd', 'diff', 'branch_name', 'paths', 'timeout'])
		self.tool_ids = self._ids_for_strings(sorted(self.tools))
		self.flag_ids = self._ids_for_strings(sorted(self.flags))
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			# Gate the first token of the quoted string to bias toward the lexeme
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		"""Very coarse state machine via regex on partial JSON text."""
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"cwd"\s*:\s*$', t):
			return 'EXPECT_CWD_STRING'
		if re.search(r'"argv"\s*:\s*$', t):
			return 'EXPECT_ARGV_ARRAY'
		if t.endswith('['):
			return 'IN_ARGV_ARRAY_START'
		if re.search(r'\[\s*"', t):
			return 'IN_ARGV_STRING'
		if re.search(r'\[\s*".*"\s*,\s*$', t):
			return 'IN_ARGV_NEXT'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			# Unexpected rank; fall back to no-op
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		s = self._state(t)
		allow: Set[int] = set()

		q = self.quote_id
		sym = self.sym

		# If we are currently inside an open quoted string, do not constrain token choices.
		# Count unescaped quotes; if odd, we're likely inside a string.
		# This keeps constraints active only at string boundaries and structure.
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores

		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':
			allow |= (self.tool_ids or set())
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGS_OBJ':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s == 'EXPECT_CWD_STRING':
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGV_ARRAY':
			if sym['['] is not None:
				allow.add(sym['['])
		elif s == 'IN_ARGV_ARRAY_START':
			if sym[']'] is not None:
				allow.add(sym[']'])
			if q is not None:
				allow.add(q)
		elif s in ('IN_ARGV_STRING', 'IN_ARGV_NEXT'):
			allow |= (self.flag_ids or set())
			if q is not None:
				allow.add(q)
			if sym[']'] is not None:
				allow.add(sym[']'])
			if sym[','] is not None:
				allow.add(sym[','])
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', '[', ']', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])

		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores

class DomJsonLogitsProcessor(LogitsProcessor):
	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:


DomJsonLogitsProcessor

				allow.add(q)
			if sym[']'] is not None:
				allow.add(sym[']'])
			if sym[','] is not None:
				allow.add(sym[','])
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', '[', ']', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])

		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores

class DomJsonLogitsProcessor(LogitsProcessor):
	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		# If inside a quoted string, do not constrain
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores
		s = self._state(t)
		allow: Set[int] = set()
		q = self.quote_id
		sym = self.sym
		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':
			allow |= (self.tool_ids or set())
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGS_OBJ':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_URL_STRING', 'EXPECT_SELECTOR_STRING'):
			if q is not None:
				allow.add(q)
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])
		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores


				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', '[', ']', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])

		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores

class DomJsonLogitsProcessor(LogitsProcessor):
	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)


		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores

class DomJsonLogitsProcessor(LogitsProcessor):
	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'


	"""
	Minimal constrained decoding for DOM actions when predicting JSON of the form:
	  {"tool":"browser.read","args":{"url":"https://...","selector":"..."}}
	We gate structure and key tokens; allow quoted strings anywhere for url/selector.
	"""

	def __init__(self, tokenizer):
		self.tok = tokenizer
		self.vocab = tokenizer.get_vocab()
		self.sym: Dict[str, Optional[int]] = {s: self._id(s) for s in ['{', '}', '[', ']', ':', ',', '"']}
		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'


		self.key_ids = self._ids_for_strings(['tool', 'args', 'url', 'selector'])
		self.tool_ids = self._ids_for_strings(['browser.read', 'browser.click_read', 'browser.form_fill'])
		self.quote_id = self.sym['"']

	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):


	def _id(self, token_str: str) -> Optional[int]:
		if token_str in self.vocab:
			return self.vocab[token_str]
		toks = self.tok.tokenize(token_str)
		return self.vocab.get(toks[0], None) if toks else None

	def _ids_for_strings(self, strings: List[str]) -> Set[int]:
		ids: Set[int] = set()
		for s in strings:
			toks = self.tok.tokenize(f'"{s}"')
			if toks:
				tid = self.vocab.get(toks[0])
				if tid is not None:
					ids.add(tid)
		return ids

	def _text(self, input_ids: torch.LongTensor) -> str:
		return self.tok.decode(input_ids[0], skip_special_tokens=True)

	def _state(self, t: str) -> str:
		t = t.lstrip()
		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		# If inside a quoted string, do not constrain
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores


		if not t:
			return 'START'
		if t.endswith('{'):
			return 'EXPECT_KEY_OR_END'
		if re.search(r'\{\s*$', t):
			return 'EXPECT_KEY'
		if re.search(r'"tool"\s*:\s*$', t):
			return 'EXPECT_TOOL_STRING'
		if re.search(r'"args"\s*:\s*$', t):
			return 'EXPECT_ARGS_OBJ'
		if re.search(r'"url"\s*:\s*$', t):
			return 'EXPECT_URL_STRING'
		if re.search(r'"selector"\s*:\s*$', t):
			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		# If inside a quoted string, do not constrain
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores
		s = self._state(t)
		allow: Set[int] = set()
		q = self.quote_id
		sym = self.sym
		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':


			return 'EXPECT_SELECTOR_STRING'
		if t.endswith('}'):
			return 'AFTER_OBJ'
		if t.endswith(','):
			return 'EXPECT_KEY'
		return 'FREE'

	def _mask_all_but(self, scores: torch.FloatTensor, allow: Set[int]) -> torch.FloatTensor:
		if not allow:
			return scores
		out = torch.full_like(scores, -float('inf'))
		idx = torch.tensor(sorted(allow), device=scores.device, dtype=torch.long)
		if scores.dim() == 2:
			out[:, idx] = scores[:, idx]
		elif scores.dim() == 1:
			out[idx] = scores[idx]
		else:
			return scores
		return out

	def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
		t = self._text(input_ids)
		# If inside a quoted string, do not constrain
		unescaped_quotes = len(re.findall(r'(?<!\\)"', t))
		if unescaped_quotes % 2 == 1:
			return scores
		s = self._state(t)
		allow: Set[int] = set()
		q = self.quote_id
		sym = self.sym
		if s == 'START':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_KEY_OR_END', 'EXPECT_KEY'):
			if q is not None:
				allow.add(q)
			if s == 'EXPECT_KEY_OR_END' and sym['}'] is not None:
				allow.add(sym['}'])
		elif s == 'EXPECT_TOOL_STRING':
			allow |= (self.tool_ids or set())
			if q is not None:
				allow.add(q)
		elif s == 'EXPECT_ARGS_OBJ':
			if sym['{'] is not None:
				allow.add(sym['{'])
		elif s in ('EXPECT_URL_STRING', 'EXPECT_SELECTOR_STRING'):
			if q is not None:
				allow.add(q)
		elif s == 'AFTER_OBJ':
			if sym[','] is not None:
				allow.add(sym[','])
			if sym['}'] is not None:
				allow.add(sym['}'])
		else:
			for k in ['"', '{', '}', ':', ',']:
				if sym[k] is not None:
					allow.add(sym[k])
		allow = {i for i in allow if i is not None}
		return self._mask_all_but(scores, allow) if allow else scores