import logging
from dataclasses import dataclass
from typing import Dict, Optional, List
from pathlib import Path
import re
import os
import fnmatch

@dataclass
class PatchAction:
	patch: str  # unified diff text
	cwd: Optional[str] = None

class PatchActuator:
	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))

		def _parse_files_and_churn(txt: str) -> Dict[str, object]:
			# Collect touched files and total churn from the unified diff text
			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():
				if line.startswith("+++ "):
					m = re.match(r"\+\+\+ \w/(.+)$", line)
					if m:
						fp = m.group(1).strip()
						if fp and fp != "/dev/null":
							files.append(fp)
				elif line.startswith("+") and (not line.startswith("+++")):
					added += 1
				elif line.startswith("-") and (not line.startswith("---")):
					deleted += 1
			unique_files = sorted({p for p in files})
			py_files = [p for p in unique_files if Path(p).suffix.lower() == ".py"]
			return {"touched_files": unique_files, "touched_py_files": py_files, "added": int(added), "deleted": int(deleted)}

		# Pre-validate diff via shared patch policy
		from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
		def _validate_diff(txt: str) -> Optional[str]:
			limits = load_env_limits(strict_default=True)
			ok, why, _meta = validate_unified_diff(txt, limits)
			return None if ok else (why or "invalid")

		viol = _validate_diff(patch_text)
		if viol is not None:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "error": f"diff_validation_failed:{viol}", **meta}

		# Write patch to a temp file (stable name incorporating content hash)
		tmp = root / f".agi_dw_tmp_{abs(hash(patch_text))}.patch"
		try:
			tmp.write_text(patch_text, encoding="utf-8")
		except Exception as e:
			return {"status": "error", "error": f"failed_to_write_patch: {e}"}

		# Optionally create/switch to a branch
		if branch_name:
			co = git.checkout(branch_name, new_branch=True)
			if co.returncode != 0:
				# Try checkout existing branch
				co2 = git.checkout(branch_name, new_branch=False)
				if co2.returncode != 0:
					return {"status": "error", "error": f"failed_to_checkout_branch: {branch_name}", "stderr": co2.stderr}

		# Dry-run
		check = git.apply_patch(str(tmp), check=True)
		if check.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "stderr": check.stderr, "stdout": check.stdout, "patch_path": str(tmp), **meta}

		# Apply for real
		apply = git.apply_patch(str(tmp), check=False)
		if apply.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "error", "stderr": apply.stderr, "stdout": apply.stdout, "patch_path": str(tmp), **meta}

		meta = _parse_files_and_churn(patch_text)
		return {"status": "applied", "stdout": apply.stdout, "stderr": apply.stderr, "patch_path": str(tmp), **meta}

	def revert_patch(self, patch_path: str, cwd: str) -> Dict:
		"""Reverse apply a previously applied patch file (git apply -R)."""
		root = Path(cwd)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))
		r = git.apply_reverse_patch(patch_path)
		return {"status": "reverted" if r.returncode == 0 else "error", "stdout": r.stdout, "stderr": r.stderr}


PatchAction

import logging
from dataclasses import dataclass
from typing import Dict, Optional, List
from pathlib import Path
import re
import os
import fnmatch

@dataclass
class PatchAction:
	patch: str  # unified diff text
	cwd: Optional[str] = None

class PatchActuator:
	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)


import logging
from dataclasses import dataclass
from typing import Dict, Optional, List
from pathlib import Path
import re
import os
import fnmatch

@dataclass
class PatchAction:
	patch: str  # unified diff text
	cwd: Optional[str] = None

class PatchActuator:
	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))

		def _parse_files_and_churn(txt: str) -> Dict[str, object]:
			# Collect touched files and total churn from the unified diff text
			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():


from pathlib import Path
import re
import os
import fnmatch

@dataclass
class PatchAction:
	patch: str  # unified diff text
	cwd: Optional[str] = None

class PatchActuator:
	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))

		def _parse_files_and_churn(txt: str) -> Dict[str, object]:
			# Collect touched files and total churn from the unified diff text
			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():
				if line.startswith("+++ "):
					m = re.match(r"\+\+\+ \w/(.+)$", line)
					if m:


import fnmatch

@dataclass
class PatchAction:
	patch: str  # unified diff text
	cwd: Optional[str] = None

class PatchActuator:
	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))

		def _parse_files_and_churn(txt: str) -> Dict[str, object]:
			# Collect touched files and total churn from the unified diff text
			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():
				if line.startswith("+++ "):
					m = re.match(r"\+\+\+ \w/(.+)$", line)
					if m:
						fp = m.group(1).strip()
						if fp and fp != "/dev/null":
							files.append(fp)
				elif line.startswith("+") and (not line.startswith("+++")):
					added += 1
				elif line.startswith("-") and (not line.startswith("---")):
					deleted += 1
			unique_files = sorted({p for p in files})
			py_files = [p for p in unique_files if Path(p).suffix.lower() == ".py"]
			return {"touched_files": unique_files, "touched_py_files": py_files, "added": int(added), "deleted": int(deleted)}

		# Pre-validate diff via shared patch policy
		from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
		def _validate_diff(txt: str) -> Optional[str]:
			limits = load_env_limits(strict_default=True)
			ok, why, _meta = validate_unified_diff(txt, limits)
			return None if ok else (why or "invalid")

		viol = _validate_diff(patch_text)
		if viol is not None:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "error": f"diff_validation_failed:{viol}", **meta}

		# Write patch to a temp file (stable name incorporating content hash)
		tmp = root / f".agi_dw_tmp_{abs(hash(patch_text))}.patch"
		try:
			tmp.write_text(patch_text, encoding="utf-8")
		except Exception as e:
			return {"status": "error", "error": f"failed_to_write_patch: {e}"}

		# Optionally create/switch to a branch
		if branch_name:
			co = git.checkout(branch_name, new_branch=True)
			if co.returncode != 0:
				# Try checkout existing branch
				co2 = git.checkout(branch_name, new_branch=False)
				if co2.returncode != 0:
					return {"status": "error", "error": f"failed_to_checkout_branch: {branch_name}", "stderr": co2.stderr}

		# Dry-run
		check = git.apply_patch(str(tmp), check=True)
		if check.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "stderr": check.stderr, "stdout": check.stdout, "patch_path": str(tmp), **meta}

		# Apply for real
		apply = git.apply_patch(str(tmp), check=False)
		if apply.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "error", "stderr": apply.stderr, "stdout": apply.stdout, "patch_path": str(tmp), **meta}

		meta = _parse_files_and_churn(patch_text)
		return {"status": "applied", "stdout": apply.stdout, "stderr": apply.stderr, "patch_path": str(tmp), **meta}

	def revert_patch(self, patch_path: str, cwd: str) -> Dict:
		"""Reverse apply a previously applied patch file (git apply -R)."""
		root = Path(cwd)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))
		r = git.apply_reverse_patch(patch_path)
		return {"status": "reverted" if r.returncode == 0 else "error", "stdout": r.stdout, "stderr": r.stderr}


				# Try checkout existing branch
				co2 = git.checkout(branch_name, new_branch=False)
				if co2.returncode != 0:
					return {"status": "error", "error": f"failed_to_checkout_branch: {branch_name}", "stderr": co2.stderr}

		# Dry-run
		check = git.apply_patch(str(tmp), check=True)
		if check.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "stderr": check.stderr, "stdout": check.stdout, "patch_path": str(tmp), **meta}

		# Apply for real
		apply = git.apply_patch(str(tmp), check=False)
		if apply.returncode != 0:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "error", "stderr": apply.stderr, "stdout": apply.stdout, "patch_path": str(tmp), **meta}

		meta = _parse_files_and_churn(patch_text)
		return {"status": "applied", "stdout": apply.stdout, "stderr": apply.stderr, "patch_path": str(tmp), **meta}

	def revert_patch(self, patch_path: str, cwd: str) -> Dict:
		"""Reverse apply a previously applied patch file (git apply -R)."""
		root = Path(cwd)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))
		r = git.apply_reverse_patch(patch_path)
		return {"status": "reverted" if r.returncode == 0 else "error", "stdout": r.stdout, "stderr": r.stderr}


	"""
	Unified-diff patch actuator.
	Applies unified diffs in a repo-agnostic sandbox/cwd with dry-run and safety validation.
	"""
	def __init__(self) -> None:
		pass

	def predict_action(self, obs: Dict, plan: Dict) -> Optional[Dict]:
		return None

	def apply_patch(self, patch_text: str, cwd: str, branch_name: Optional[str] = None) -> Dict:
		"""Apply a unified diff within a git repo. Optionally create/switch to a branch before applying.

		Returns dict with status and patch file path so callers can revert later.
		"""
		root = Path(cwd)
		root.mkdir(parents=True, exist_ok=True)
		from agi_dw.tools.git import GitTool
		git = GitTool(str(root))

		def _parse_files_and_churn(txt: str) -> Dict[str, object]:
			# Collect touched files and total churn from the unified diff text
			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():
				if line.startswith("+++ "):
					m = re.match(r"\+\+\+ \w/(.+)$", line)
					if m:
						fp = m.group(1).strip()
						if fp and fp != "/dev/null":
							files.append(fp)
				elif line.startswith("+") and (not line.startswith("+++")):
					added += 1
				elif line.startswith("-") and (not line.startswith("---")):
					deleted += 1
			unique_files = sorted({p for p in files})
			py_files = [p for p in unique_files if Path(p).suffix.lower() == ".py"]
			return {"touched_files": unique_files, "touched_py_files": py_files, "added": int(added), "deleted": int(deleted)}

		# Pre-validate diff via shared patch policy
		from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
		def _validate_diff(txt: str) -> Optional[str]:
			limits = load_env_limits(strict_default=True)
			ok, why, _meta = validate_unified_diff(txt, limits)
			return None if ok else (why or "invalid")

		viol = _validate_diff(patch_text)
		if viol is not None:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "error": f"diff_validation_failed:{viol}", **meta}

		# Write patch to a temp file (stable name incorporating content hash)
		tmp = root / f".agi_dw_tmp_{abs(hash(patch_text))}.patch"
		try:
			tmp.write_text(patch_text, encoding="utf-8")
		except Exception as e:
			return {"status": "error", "error": f"failed_to_write_patch: {e}"}


			files: list[str] = []
			added = 0
			deleted = 0
			for line in txt.splitlines():
				if line.startswith("+++ "):
					m = re.match(r"\+\+\+ \w/(.+)$", line)
					if m:
						fp = m.group(1).strip()
						if fp and fp != "/dev/null":
							files.append(fp)
				elif line.startswith("+") and (not line.startswith("+++")):
					added += 1
				elif line.startswith("-") and (not line.startswith("---")):
					deleted += 1
			unique_files = sorted({p for p in files})
			py_files = [p for p in unique_files if Path(p).suffix.lower() == ".py"]
			return {"touched_files": unique_files, "touched_py_files": py_files, "added": int(added), "deleted": int(deleted)}

		# Pre-validate diff via shared patch policy
		from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
		def _validate_diff(txt: str) -> Optional[str]:
			limits = load_env_limits(strict_default=True)
			ok, why, _meta = validate_unified_diff(txt, limits)
			return None if ok else (why or "invalid")

		viol = _validate_diff(patch_text)
		if viol is not None:
			meta = _parse_files_and_churn(patch_text)
			return {"status": "invalid", "error": f"diff_validation_failed:{viol}", **meta}

		# Write patch to a temp file (stable name incorporating content hash)
		tmp = root / f".agi_dw_tmp_{abs(hash(patch_text))}.patch"
		try:
			tmp.write_text(patch_text, encoding="utf-8")
		except Exception as e:
			return {"status": "error", "error": f"failed_to_write_patch: {e}"}

		# Optionally create/switch to a branch
		if branch_name:
			co = git.checkout(branch_name, new_branch=True)
			if co.returncode != 0:
				# Try checkout existing branch
				co2 = git.checkout(branch_name, new_branch=False)
				if co2.returncode != 0:


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

from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, LogitsProcessorList, NoBadWordsLogitsProcessor, RepetitionPenaltyLogitsProcessor
import torch
import re

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml, parse_dom_pred_text
from agi_dw.core.actuator.json_constraints import JsonSchemaLogitsProcessor, DomJsonLogitsProcessor

CLI_INSTRUCTION = (
	'Actuator task: Return ONLY the CLI argv as a single space-separated string. '
	'Example: wc -l docs/a.txt. No quotes, no explanations, no extra text. Input follows:\n'
)

DOM_INSTRUCTION = (
	'DOM task: Return ONLY two tokens: the URL and the CSS selector, separated by a single space. '
	'Example: https://example.com h1. No quotes, no explanations, no extra text. Input follows:\n'
)

class ActuatorT5Predictor:
	def __init__(self, model_dir: str, mode: str = "cli", structured: bool = False) -> None:
		self.model_dir = model_dir
		self.mode = mode
		self.structured = bool(structured)
		self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
		self.model = AutoModelForSeq2SeqLM.from_pretrained(model_dir)
		# Prefer GPU half precision when available for speed
		if torch.cuda.is_available():
			try:
				self.model.half()
			except Exception:
				pass
		self.model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

	def _generate_with_constraints(self, inputs, max_new_tokens: int = 64):
		lp = LogitsProcessorList()
		# Suppress copying input keys (works for both CLI and DOM prompts)
		bad_words = [
			self.tokenizer.encode(w, add_special_tokens=False)
			for w in ["obs", "plan", "kind", "subgoals", "tools", "constraints", "meta", "content"]
		]
		bad_words = [bw for bw in bad_words if len(bw) > 0]
		if bad_words:
			lp.append(NoBadWordsLogitsProcessor(bad_words_ids=bad_words, eos_token_id=self.tokenizer.eos_token_id))
		# Light repetition penalty to reduce echoing
		lp.append(RepetitionPenaltyLogitsProcessor(penalty=1.15))
		# Add DOM JSON constraints when structured DOM mode is enabled
		if self.mode == "dom" and self.structured:
			try:
				lp.append(DomJsonLogitsProcessor(self.tokenizer))
			except Exception:
				pass
		# Avoid CLI JSON constraints here since CLI outputs are argv-only
		return self.model.generate(
			**inputs,
			max_new_tokens=max_new_tokens,
			logits_processor=lp,
			do_sample=False,
			num_beams=1,
		)

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any], max_new_tokens: int = 128) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		if self.mode == "cli":
			instruction = CLI_INSTRUCTION
		else:
			# If structured, allow YAML/JSON with url/selector fields for robustness
			if self.structured:
				instruction = (
					"DOM task: Return ONLY a YAML or JSON object with url and selector keys. "
					"Example YAML: url: https://example.com\nselector: h1. No prose. Input follows:\n"
				)
			else:
				instruction = DOM_INSTRUCTION
		prompt = instruction + input_text
		pred_text = ""
		# Optional Outlines JSON for structured DOM
		if self.mode == "dom" and self.structured:
			try:
				import os as _os  # type: ignore
				if _os.environ.get("AGI_DOM_OUTLINES", "0") in ("1", "true", "True"):
					from outlines import models as _out_models  # type: ignore
					from outlines import generate as _out_generate  # type: ignore
					schema = {
						"type": "object",
						"properties": {
							"tool": {"type": "string"},
							"args": {
								"type": "object",
								"properties": {"url": {"type": "string"}, "selector": {"type": "string"}},
								"required": ["url", "selector"],
								"additionalProperties": True,
							},
						},
						"required": ["args"],
						"additionalProperties": True,
					}
					base_prompt = (
						"DOM task: Return ONLY a JSON object with fields tool and args{url,selector}. No prose.\n"
						+ input_text
					)
					mdl = _out_models.transformers(self.model_dir)
					generator = _out_generate.json(mdl, schema)
					pred_text = str(generator(base_prompt)).strip()
			except Exception:
				pred_text = ""
		if not pred_text:
			inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True)
			inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
			outputs = self._generate_with_constraints(inputs, max_new_tokens=min(max_new_tokens, 64))
			pred_text = self.tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
		if self.mode == "cli":
			argv = [t for t in pred_text.split() if t]
			cwd = (obs.get("meta") or {}).get("cwd") or "."
			return {"tool": "cli.run", "args": {"argv": argv, "cwd": cwd}}
		else:
			meta = (obs.get("meta") or {}) if isinstance(obs, dict) else {}
			default_url = str(meta.get("url", ""))
			default_selector = str(meta.get("selector", ""))
			# Structured first: try YAML/JSON parsing helper
			action = parse_dom_pred_text(pred_text, default_url=default_url, default_selector=default_selector)
			if isinstance(action, dict) and isinstance(action.get("args"), dict):
				url = str(action["args"].get("url", default_url)).strip().rstrip('/')
				selector = re.sub(r"\s+", " ", str(action["args"].get("selector", default_selector)).strip())
				return {"tool": str(action.get("tool", "browser.read")), "args": {"url": url, "selector": selector}}
			# Fallback to simple two-token format
			parts = [t for t in pred_text.split() if t]
			url = parts[0] if len(parts) > 0 else default_url
			selector = parts[1] if len(parts) > 1 else default_selector
			url = str(url).strip().rstrip('/')
			selector = re.sub(r"\s+", " ", str(selector).strip())
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}


from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, LogitsProcessorList, NoBadWordsLogitsProcessor, RepetitionPenaltyLogitsProcessor
import torch
import re

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml, parse_dom_pred_text
from agi_dw.core.actuator.json_constraints import JsonSchemaLogitsProcessor, DomJsonLogitsProcessor

CLI_INSTRUCTION = (
	'Actuator task: Return ONLY the CLI argv as a single space-separated string. '
	'Example: wc -l docs/a.txt. No quotes, no explanations, no extra text. Input follows:\n'
)

DOM_INSTRUCTION = (
	'DOM task: Return ONLY two tokens: the URL and the CSS selector, separated by a single space. '
	'Example: https://example.com h1. No quotes, no explanations, no extra text. Input follows:\n'
)

class ActuatorT5Predictor:
	def __init__(self, model_dir: str, mode: str = "cli", structured: bool = False) -> None:
		self.model_dir = model_dir
		self.mode = mode
		self.structured = bool(structured)
		self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
		self.model = AutoModelForSeq2SeqLM.from_pretrained(model_dir)
		# Prefer GPU half precision when available for speed
		if torch.cuda.is_available():
			try:
				self.model.half()
			except Exception:
				pass
		self.model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

	def _generate_with_constraints(self, inputs, max_new_tokens: int = 64):
		lp = LogitsProcessorList()
		# Suppress copying input keys (works for both CLI and DOM prompts)
		bad_words = [
			self.tokenizer.encode(w, add_special_tokens=False)
			for w in ["obs", "plan", "kind", "subgoals", "tools", "constraints", "meta", "content"]
		]
		bad_words = [bw for bw in bad_words if len(bw) > 0]
		if bad_words:
			lp.append(NoBadWordsLogitsProcessor(bad_words_ids=bad_words, eos_token_id=self.tokenizer.eos_token_id))
		# Light repetition penalty to reduce echoing
		lp.append(RepetitionPenaltyLogitsProcessor(penalty=1.15))
		# Add DOM JSON constraints when structured DOM mode is enabled
		if self.mode == "dom" and self.structured:
			try:
				lp.append(DomJsonLogitsProcessor(self.tokenizer))
			except Exception:
				pass
		# Avoid CLI JSON constraints here since CLI outputs are argv-only
		return self.model.generate(
			**inputs,
			max_new_tokens=max_new_tokens,
			logits_processor=lp,
			do_sample=False,
			num_beams=1,
		)

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any], max_new_tokens: int = 128) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		if self.mode == "cli":
			instruction = CLI_INSTRUCTION
		else:
			# If structured, allow YAML/JSON with url/selector fields for robustness
			if self.structured:
				instruction = (
					"DOM task: Return ONLY a YAML or JSON object with url and selector keys. "
					"Example YAML: url: https://example.com\nselector: h1. No prose. Input follows:\n"
				)
			else:
				instruction = DOM_INSTRUCTION
		prompt = instruction + input_text
		pred_text = ""
		# Optional Outlines JSON for structured DOM
		if self.mode == "dom" and self.structured:
			try:
				import os as _os  # type: ignore
				if _os.environ.get("AGI_DOM_OUTLINES", "0") in ("1", "true", "True"):
					from outlines import models as _out_models  # type: ignore
					from outlines import generate as _out_generate  # type: ignore
					schema = {
						"type": "object",
						"properties": {
							"tool": {"type": "string"},
							"args": {
								"type": "object",
								"properties": {"url": {"type": "string"}, "selector": {"type": "string"}},
								"required": ["url", "selector"],
								"additionalProperties": True,
							},
						},
						"required": ["args"],
						"additionalProperties": True,
					}
					base_prompt = (
						"DOM task: Return ONLY a JSON object with fields tool and args{url,selector}. No prose.\n"
						+ input_text
					)
					mdl = _out_models.transformers(self.model_dir)
					generator = _out_generate.json(mdl, schema)
					pred_text = str(generator(base_prompt)).strip()
			except Exception:
				pred_text = ""
		if not pred_text:
			inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True)
			inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
			outputs = self._generate_with_constraints(inputs, max_new_tokens=min(max_new_tokens, 64))
			pred_text = self.tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
		if self.mode == "cli":
			argv = [t for t in pred_text.split() if t]
			cwd = (obs.get("meta") or {}).get("cwd") or "."
			return {"tool": "cli.run", "args": {"argv": argv, "cwd": cwd}}
		else:
			meta = (obs.get("meta") or {}) if isinstance(obs, dict) else {}
			default_url = str(meta.get("url", ""))
			default_selector = str(meta.get("selector", ""))
			# Structured first: try YAML/JSON parsing helper
			action = parse_dom_pred_text(pred_text, default_url=default_url, default_selector=default_selector)
			if isinstance(action, dict) and isinstance(action.get("args"), dict):
				url = str(action["args"].get("url", default_url)).strip().rstrip('/')
				selector = re.sub(r"\s+", " ", str(action["args"].get("selector", default_selector)).strip())
				return {"tool": str(action.get("tool", "browser.read")), "args": {"url": url, "selector": selector}}
			# Fallback to simple two-token format
			parts = [t for t in pred_text.split() if t]
			url = parts[0] if len(parts) > 0 else default_url
			selector = parts[1] if len(parts) > 1 else default_selector
			url = str(url).strip().rstrip('/')
			selector = re.sub(r"\s+", " ", str(selector).strip())
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}


from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, LogitsProcessorList, NoBadWordsLogitsProcessor, RepetitionPenaltyLogitsProcessor
import torch
import re

from agi_dw.core.actuator.parse import parse_yaml_or_json, coerce_flat_yaml, parse_dom_pred_text
from agi_dw.core.actuator.json_constraints import JsonSchemaLogitsProcessor, DomJsonLogitsProcessor

CLI_INSTRUCTION = (
	'Actuator task: Return ONLY the CLI argv as a single space-separated string. '
	'Example: wc -l docs/a.txt. No quotes, no explanations, no extra text. Input follows:\n'
)

DOM_INSTRUCTION = (
	'DOM task: Return ONLY two tokens: the URL and the CSS selector, separated by a single space. '
	'Example: https://example.com h1. No quotes, no explanations, no extra text. Input follows:\n'
)

class ActuatorT5Predictor:
	def __init__(self, model_dir: str, mode: str = "cli", structured: bool = False) -> None:
		self.model_dir = model_dir
		self.mode = mode
		self.structured = bool(structured)
		self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
		self.model = AutoModelForSeq2SeqLM.from_pretrained(model_dir)
		# Prefer GPU half precision when available for speed
		if torch.cuda.is_available():
			try:
				self.model.half()
			except Exception:
				pass
		self.model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

	def _generate_with_constraints(self, inputs, max_new_tokens: int = 64):
		lp = LogitsProcessorList()
		# Suppress copying input keys (works for both CLI and DOM prompts)
		bad_words = [
			self.tokenizer.encode(w, add_special_tokens=False)
			for w in ["obs", "plan", "kind", "subgoals", "tools", "constraints", "meta", "content"]
		]
		bad_words = [bw for bw in bad_words if len(bw) > 0]
		if bad_words:
			lp.append(NoBadWordsLogitsProcessor(bad_words_ids=bad_words, eos_token_id=self.tokenizer.eos_token_id))
		# Light repetition penalty to reduce echoing
		lp.append(RepetitionPenaltyLogitsProcessor(penalty=1.15))
		# Add DOM JSON constraints when structured DOM mode is enabled
		if self.mode == "dom" and self.structured:
			try:
				lp.append(DomJsonLogitsProcessor(self.tokenizer))
			except Exception:
				pass
		# Avoid CLI JSON constraints here since CLI outputs are argv-only


	'DOM task: Return ONLY two tokens: the URL and the CSS selector, separated by a single space. '
	'Example: https://example.com h1. No quotes, no explanations, no extra text. Input follows:\n'
)

class ActuatorT5Predictor:
	def __init__(self, model_dir: str, mode: str = "cli", structured: bool = False) -> None:
		self.model_dir = model_dir
		self.mode = mode
		self.structured = bool(structured)
		self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
		self.model = AutoModelForSeq2SeqLM.from_pretrained(model_dir)
		# Prefer GPU half precision when available for speed
		if torch.cuda.is_available():
			try:
				self.model.half()
			except Exception:
				pass
		self.model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

	def _generate_with_constraints(self, inputs, max_new_tokens: int = 64):
		lp = LogitsProcessorList()
		# Suppress copying input keys (works for both CLI and DOM prompts)
		bad_words = [
			self.tokenizer.encode(w, add_special_tokens=False)
			for w in ["obs", "plan", "kind", "subgoals", "tools", "constraints", "meta", "content"]
		]
		bad_words = [bw for bw in bad_words if len(bw) > 0]
		if bad_words:
			lp.append(NoBadWordsLogitsProcessor(bad_words_ids=bad_words, eos_token_id=self.tokenizer.eos_token_id))
		# Light repetition penalty to reduce echoing
		lp.append(RepetitionPenaltyLogitsProcessor(penalty=1.15))
		# Add DOM JSON constraints when structured DOM mode is enabled
		if self.mode == "dom" and self.structured:
			try:
				lp.append(DomJsonLogitsProcessor(self.tokenizer))
			except Exception:
				pass
		# Avoid CLI JSON constraints here since CLI outputs are argv-only
		return self.model.generate(
			**inputs,
			max_new_tokens=max_new_tokens,
			logits_processor=lp,
			do_sample=False,
			num_beams=1,
		)

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any], max_new_tokens: int = 128) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		if self.mode == "cli":
			instruction = CLI_INSTRUCTION
		else:
			# If structured, allow YAML/JSON with url/selector fields for robustness
			if self.structured:
				instruction = (
					"DOM task: Return ONLY a YAML or JSON object with url and selector keys. "
					"Example YAML: url: https://example.com\nselector: h1. No prose. Input follows:\n"
				)
			else:
				instruction = DOM_INSTRUCTION
		prompt = instruction + input_text
		pred_text = ""
		# Optional Outlines JSON for structured DOM
		if self.mode == "dom" and self.structured:
			try:
				import os as _os  # type: ignore


		bad_words = [bw for bw in bad_words if len(bw) > 0]
		if bad_words:
			lp.append(NoBadWordsLogitsProcessor(bad_words_ids=bad_words, eos_token_id=self.tokenizer.eos_token_id))
		# Light repetition penalty to reduce echoing
		lp.append(RepetitionPenaltyLogitsProcessor(penalty=1.15))
		# Add DOM JSON constraints when structured DOM mode is enabled
		if self.mode == "dom" and self.structured:
			try:
				lp.append(DomJsonLogitsProcessor(self.tokenizer))
			except Exception:
				pass
		# Avoid CLI JSON constraints here since CLI outputs are argv-only
		return self.model.generate(
			**inputs,
			max_new_tokens=max_new_tokens,
			logits_processor=lp,
			do_sample=False,
			num_beams=1,
		)

	def predict_action(self, obs: Dict[str, Any], plan: Dict[str, Any], max_new_tokens: int = 128) -> Dict[str, Any]:
		input_text = json.dumps({"obs": obs, "plan": plan}, ensure_ascii=False)
		if self.mode == "cli":
			instruction = CLI_INSTRUCTION
		else:
			# If structured, allow YAML/JSON with url/selector fields for robustness
			if self.structured:
				instruction = (
					"DOM task: Return ONLY a YAML or JSON object with url and selector keys. "
					"Example YAML: url: https://example.com\nselector: h1. No prose. Input follows:\n"
				)
			else:
				instruction = DOM_INSTRUCTION
		prompt = instruction + input_text
		pred_text = ""
		# Optional Outlines JSON for structured DOM
		if self.mode == "dom" and self.structured:
			try:
				import os as _os  # type: ignore
				if _os.environ.get("AGI_DOM_OUTLINES", "0") in ("1", "true", "True"):
					from outlines import models as _out_models  # type: ignore
					from outlines import generate as _out_generate  # type: ignore
					schema = {
						"type": "object",
						"properties": {
							"tool": {"type": "string"},
							"args": {
								"type": "object",
								"properties": {"url": {"type": "string"}, "selector": {"type": "string"}},
								"required": ["url", "selector"],
								"additionalProperties": True,
							},
						},
						"required": ["args"],
						"additionalProperties": True,
					}
					base_prompt = (
						"DOM task: Return ONLY a JSON object with fields tool and args{url,selector}. No prose.\n"
						+ input_text
					)
					mdl = _out_models.transformers(self.model_dir)
					generator = _out_generate.json(mdl, schema)
					pred_text = str(generator(base_prompt)).strip()
			except Exception:
				pred_text = ""
		if not pred_text:
			inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True)
			inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
			outputs = self._generate_with_constraints(inputs, max_new_tokens=min(max_new_tokens, 64))
			pred_text = self.tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
		if self.mode == "cli":
			argv = [t for t in pred_text.split() if t]
			cwd = (obs.get("meta") or {}).get("cwd") or "."
			return {"tool": "cli.run", "args": {"argv": argv, "cwd": cwd}}
		else:
			meta = (obs.get("meta") or {}) if isinstance(obs, dict) else {}
			default_url = str(meta.get("url", ""))
			default_selector = str(meta.get("selector", ""))
			# Structured first: try YAML/JSON parsing helper
			action = parse_dom_pred_text(pred_text, default_url=default_url, default_selector=default_selector)
			if isinstance(action, dict) and isinstance(action.get("args"), dict):
				url = str(action["args"].get("url", default_url)).strip().rstrip('/')
				selector = re.sub(r"\s+", " ", str(action["args"].get("selector", default_selector)).strip())
				return {"tool": str(action.get("tool", "browser.read")), "args": {"url": url, "selector": selector}}
			# Fallback to simple two-token format
			parts = [t for t in pred_text.split() if t]
			url = parts[0] if len(parts) > 0 else default_url
			selector = parts[1] if len(parts) > 1 else default_selector
			url = str(url).strip().rstrip('/')
			selector = re.sub(r"\s+", " ", str(selector).strip())
			return {"tool": "browser.read", "args": {"url": url, "selector": selector}}


from __future__ import annotations

import logging
import json
from typing import Any, Dict, List, Optional
import math

def extract_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	features: Dict[str, float] = {}
	plan_text = json.dumps(plan, ensure_ascii=False) if not isinstance(plan, str) else plan
	features["plan_len"] = float(len(plan_text))
	# Token stats
	tokens: List[str] = str(plan_text).split()
	features["plan_ws_tokens"] = float(len(tokens))
	unique_tokens = len(set(tokens)) if tokens else 0
	features["plan_unique_tokens"] = float(unique_tokens)
	# Simple token entropy over whitespace tokens
	if tokens:
		from collections import Counter
		cnt = Counter(tokens)
		n = float(len(tokens))
		entropy = 0.0
		for c in cnt.values():
			p = c / n
			if p > 0:
				entropy -= p * math.log(p + 1e-12)
		features["plan_token_entropy"] = float(entropy)
	else:
		features["plan_token_entropy"] = 0.0
	features["obs_cli"] = 1.0 if (obs or {}).get("kind") == "cli" else 0.0
	features["obs_dom"] = 1.0 if (obs or {}).get("kind") == "dom" else 0.0
	# DOM-aware observation features
	if features["obs_dom"] == 1.0:
		try:
			meta = (obs or {}).get("meta", {})
			url = str(meta.get("url", "")) if isinstance(meta, dict) else ""
			selector = str(meta.get("selector", "")) if isinstance(meta, dict) else ""
			features["dom_url_present"] = 1.0 if url else 0.0
			features["dom_url_len"] = float(len(url))
			lu = url.lower()
			features["dom_url_http"] = 1.0 if (lu.startswith("http://") or lu.startswith("https://")) else 0.0
			features["dom_url_www"] = 1.0 if ("//www." in lu) else 0.0
			features["dom_selector_present"] = 1.0 if selector else 0.0
			features["dom_selector_len"] = float(len(selector))
			features["dom_selector_has_id"] = 1.0 if ("#" in selector) else 0.0
			features["dom_selector_has_class"] = 1.0 if ("." in selector) else 0.0
			features["dom_selector_has_attr"] = 1.0 if ("[" in selector and "]" in selector) else 0.0
			features["dom_selector_spaces"] = float(selector.count(" "))
			features["dom_selector_commas"] = float(selector.count(","))
			# New: count of descendant/child combinators and attribute predicates
			features["dom_selector_child_ops"] = float(selector.count(">"))
			features["dom_selector_attr_predicates"] = float(selector.count("["))
		# DOM confidence features: entropy, schema errors, verifier risk
			# Entropy of selector tokens (higher = more complex/uncertain)
			sel_tokens = selector.split() if selector else []
			if sel_tokens:
				from collections import Counter
				cnt = Counter(sel_tokens)
				n = float(len(sel_tokens))
				entropy = 0.0
				for c in cnt.values():
					p = c / n
					if p > 0:
						entropy -= p * math.log(p + 1e-12)
				features["dom_selector_entropy"] = float(entropy)
			else:
				features["dom_selector_entropy"] = 0.0
			# Schema error indicators (malformed selectors)
			features["dom_selector_malformed"] = 0.0
			if selector:
				# Check for unbalanced brackets/parens
				open_brackets = selector.count("[")
				close_brackets = selector.count("]")
				open_parens = selector.count("(")
				close_parens = selector.count(")")
				if open_brackets != close_brackets or open_parens != close_parens:
					features["dom_selector_malformed"] = 1.0
				# Check for invalid CSS patterns
				invalid_patterns = ["..", "##", "  ", "::", "++", "--"]
				for pattern in invalid_patterns:
					if pattern in selector:
						features["dom_selector_malformed"] = 1.0
						break
			# Verifier risk for DOM actions (if available)
			if extras and extras.get("verifier_risk") is not None:
				try:
					vr = float(extras.get("verifier_risk"))
					features["dom_verifier_risk"] = vr
				except Exception:
					features["dom_verifier_risk"] = 0.5
			else:
				features["dom_verifier_risk"] = 0.5
		except Exception:
			# Keep robust; DOM confidence features are optional
			features["dom_selector_entropy"] = 0.0
			features["dom_selector_malformed"] = 0.0
			features["dom_verifier_risk"] = 0.5
	# Subgoals count if present
	subgoals = []
	if isinstance(plan, dict):
		sg = plan.get("subgoals")
		if isinstance(sg, list):
			subgoals = sg
	features["num_subgoals"] = float(len(subgoals))
	# Keyword indicators for phase-1 tools
	for kw in ["wc", "grep", "head", "tail", "sort", "uniq", "cut"]:
		features[f"kw_{kw}"] = 1.0 if kw in plan_text.lower() else 0.0
	# Optional auxiliaries: verifier and world model priors
	if extras:
		try:
			vr = float(extras.get("verifier_risk")) if extras.get("verifier_risk") is not None else None
			if vr is not None:
				features["verifier_risk"] = vr
		except Exception:
			pass
		try:
			wm = extras.get("wm_prior") if isinstance(extras.get("wm_prior"), dict) else extras
			if isinstance(wm, dict):
				sp = wm.get("success_prob") if wm.get("success_prob") is not None else wm.get("wm_success_prob")
				rk = wm.get("risk") if wm.get("risk") is not None else wm.get("wm_risk")
				if sp is not None:
					features["wm_success_prob"] = float(sp)
				if rk is not None:
					features["wm_risk"] = float(rk)
			# Pass-through any explicitly provided numeric extras under a namespaced key
			for k, v in wm.items():
				if isinstance(v, (int, float)) and k not in ("success_prob", "risk", "wm_success_prob", "wm_risk"):
					features[f"extra_{k}"] = float(v)
		except Exception:
			pass
	return features

def features_to_vector(features: Dict[str, float], keys: List[str]) -> List[float]:
	return [float(features.get(k, 0.0)) for k in keys]

def load_router_model(model_path: str) -> Optional[Dict[str, Any]]:
	"""Load a trained router model from disk."""
	try:
		import joblib  # type: ignore
		payload = joblib.load(model_path)
		return payload
	except Exception:
		return None

def get_task_success_threshold(payload: Dict[str, Any], task: str) -> float:
	"""Get per-task success threshold if available, otherwise use global threshold."""
	if not isinstance(payload, dict):
		return 0.5

	# Check for per-task thresholds
	thresholds = payload.get("thresholds", {})
	if isinstance(thresholds, dict) and task in thresholds:
		return float(thresholds[task])

	# Fall back to global threshold
	threshold = payload.get("threshold")
	if isinstance(threshold, (int, float)):
		return float(threshold)

	# Default threshold
	return 0.5


from __future__ import annotations

import logging
import json
from typing import Any, Dict, List, Optional
import math

def extract_router_features(obs: Dict[str, Any], plan: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
	features: Dict[str, float] = {}
	plan_text = json.dumps(plan, ensure_ascii=False) if not isinstance(plan, str) else plan
	features["plan_len"] = float(len(plan_text))
	# Token stats
	tokens: List[str] = str(plan_text).split()
	features["plan_ws_tokens"] = float(len(tokens))
	unique_tokens = len(set(tokens)) if tokens else 0
	features["plan_unique_tokens"] = float(unique_tokens)
	# Simple token entropy over whitespace tokens
	if tokens:
		from collections import Counter
		cnt = Counter(tokens)
		n = float(len(tokens))
		entropy = 0.0
		for c in cnt.values():
			p = c / n
			if p > 0:
				entropy -= p * math.log(p + 1e-12)
		features["plan_token_entropy"] = float(entropy)
	else:
		features["plan_token_entropy"] = 0.0
	features["obs_cli"] = 1.0 if (obs or {}).get("kind") == "cli" else 0.0
	features["obs_dom"] = 1.0 if (obs or {}).get("kind") == "dom" else 0.0
	# DOM-aware observation features
	if features["obs_dom"] == 1.0:
		try:
			meta = (obs or {}).get("meta", {})
			url = str(meta.get("url", "")) if isinstance(meta, dict) else ""
			selector = str(meta.get("selector", "")) if isinstance(meta, dict) else ""
			features["dom_url_present"] = 1.0 if url else 0.0
			features["dom_url_len"] = float(len(url))
			lu = url.lower()
			features["dom_url_http"] = 1.0 if (lu.startswith("http://") or lu.startswith("https://")) else 0.0
			features["dom_url_www"] = 1.0 if ("//www." in lu) else 0.0
			features["dom_selector_present"] = 1.0 if selector else 0.0
			features["dom_selector_len"] = float(len(selector))
			features["dom_selector_has_id"] = 1.0 if ("#" in selector) else 0.0
			features["dom_selector_has_class"] = 1.0 if ("." in selector) else 0.0
			features["dom_selector_has_attr"] = 1.0 if ("[" in selector and "]" in selector) else 0.0
			features["dom_selector_spaces"] = float(selector.count(" "))
			features["dom_selector_commas"] = float(selector.count(","))
			# New: count of descendant/child combinators and attribute predicates
			features["dom_selector_child_ops"] = float(selector.count(">"))
			features["dom_selector_attr_predicates"] = float(selector.count("["))
		# DOM confidence features: entropy, schema errors, verifier risk
			# Entropy of selector tokens (higher = more complex/uncertain)
			sel_tokens = selector.split() if selector else []
			if sel_tokens:
				from collections import Counter
				cnt = Counter(sel_tokens)
				n = float(len(sel_tokens))
				entropy = 0.0
				for c in cnt.values():
					p = c / n
					if p > 0:
						entropy -= p * math.log(p + 1e-12)
				features["dom_selector_entropy"] = float(entropy)
			else:
				features["dom_selector_entropy"] = 0.0
			# Schema error indicators (malformed selectors)
			features["dom_selector_malformed"] = 0.0
			if selector:
				# Check for unbalanced brackets/parens
				open_brackets = selector.count("[")
				close_brackets = selector.count("]")
				open_parens = selector.count("(")
				close_parens = selector.count(")")
				if open_brackets != close_brackets or open_parens != close_parens:
					features["dom_selector_malformed"] = 1.0
				# Check for invalid CSS patterns
				invalid_patterns = ["..", "##", "  ", "::", "++", "--"]
				for pattern in invalid_patterns:
					if pattern in selector:
						features["dom_selector_malformed"] = 1.0
						break
			# Verifier risk for DOM actions (if available)
			if extras and extras.get("verifier_risk") is not None:
				try:
					vr = float(extras.get("verifier_risk"))
					features["dom_verifier_risk"] = vr
				except Exception:
					features["dom_verifier_risk"] = 0.5
			else:
				features["dom_verifier_risk"] = 0.5
		except Exception:
			# Keep robust; DOM confidence features are optional
			features["dom_selector_entropy"] = 0.0
			features["dom_selector_malformed"] = 0.0
			features["dom_verifier_risk"] = 0.5
	# Subgoals count if present
	subgoals = []
	if isinstance(plan, dict):
		sg = plan.get("subgoals")
		if isinstance(sg, list):
			subgoals = sg
	features["num_subgoals"] = float(len(subgoals))
	# Keyword indicators for phase-1 tools
	for kw in ["wc", "grep", "head", "tail", "sort", "uniq", "cut"]:
		features[f"kw_{kw}"] = 1.0 if kw in plan_text.lower() else 0.0
	# Optional auxiliaries: verifier and world model priors
	if extras:
		try:
			vr = float(extras.get("verifier_risk")) if extras.get("verifier_risk") is not None else None
			if vr is not None:
				features["verifier_risk"] = vr
		except Exception:
			pass
		try:
			wm = extras.get("wm_prior") if isinstance(extras.get("wm_prior"), dict) else extras
			if isinstance(wm, dict):
				sp = wm.get("success_prob") if wm.get("success_prob") is not None else wm.get("wm_success_prob")
				rk = wm.get("risk") if wm.get("risk") is not None else wm.get("wm_risk")
				if sp is not None:
					features["wm_success_prob"] = float(sp)
				if rk is not None:
					features["wm_risk"] = float(rk)
			# Pass-through any explicitly provided numeric extras under a namespaced key
			for k, v in wm.items():
				if isinstance(v, (int, float)) and k not in ("success_prob", "risk", "wm_success_prob", "wm_risk"):
					features[f"extra_{k}"] = float(v)
		except Exception:
			pass
	return features

def features_to_vector(features: Dict[str, float], keys: List[str]) -> List[float]:
	return [float(features.get(k, 0.0)) for k in keys]

def load_router_model(model_path: str) -> Optional[Dict[str, Any]]:
	"""Load a trained router model from disk."""
	try:
		import joblib  # type: ignore
		payload = joblib.load(model_path)
		return payload
	except Exception:
		return None

def get_task_success_threshold(payload: Dict[str, Any], task: str) -> float:
	"""Get per-task success threshold if available, otherwise use global threshold."""
	if not isinstance(payload, dict):
		return 0.5


		except Exception:
			pass
		try:
			wm = extras.get("wm_prior") if isinstance(extras.get("wm_prior"), dict) else extras
			if isinstance(wm, dict):
				sp = wm.get("success_prob") if wm.get("success_prob") is not None else wm.get("wm_success_prob")
				rk = wm.get("risk") if wm.get("risk") is not None else wm.get("wm_risk")
				if sp is not None:
					features["wm_success_prob"] = float(sp)
				if rk is not None:
					features["wm_risk"] = float(rk)
			# Pass-through any explicitly provided numeric extras under a namespaced key
			for k, v in wm.items():
				if isinstance(v, (int, float)) and k not in ("success_prob", "risk", "wm_success_prob", "wm_risk"):
					features[f"extra_{k}"] = float(v)
		except Exception:
			pass
	return features

def features_to_vector(features: Dict[str, float], keys: List[str]) -> List[float]:
	return [float(features.get(k, 0.0)) for k in keys]

def load_router_model(model_path: str) -> Optional[Dict[str, Any]]:
	"""Load a trained router model from disk."""
	try:
		import joblib  # type: ignore
		payload = joblib.load(model_path)
		return payload
	except Exception:
		return None

def get_task_success_threshold(payload: Dict[str, Any], task: str) -> float:
	"""Get per-task success threshold if available, otherwise use global threshold."""
	if not isinstance(payload, dict):
		return 0.5

	# Check for per-task thresholds
	thresholds = payload.get("thresholds", {})
	if isinstance(thresholds, dict) and task in thresholds:


			if isinstance(wm, dict):
				sp = wm.get("success_prob") if wm.get("success_prob") is not None else wm.get("wm_success_prob")
				rk = wm.get("risk") if wm.get("risk") is not None else wm.get("wm_risk")
				if sp is not None:
					features["wm_success_prob"] = float(sp)
				if rk is not None:
					features["wm_risk"] = float(rk)
			# Pass-through any explicitly provided numeric extras under a namespaced key
			for k, v in wm.items():
				if isinstance(v, (int, float)) and k not in ("success_prob", "risk", "wm_success_prob", "wm_risk"):
					features[f"extra_{k}"] = float(v)
		except Exception:
			pass
	return features

def features_to_vector(features: Dict[str, float], keys: List[str]) -> List[float]:
	return [float(features.get(k, 0.0)) for k in keys]

def load_router_model(model_path: str) -> Optional[Dict[str, Any]]:
	"""Load a trained router model from disk."""
	try:
		import joblib  # type: ignore
		payload = joblib.load(model_path)
		return payload
	except Exception:
		return None

def get_task_success_threshold(payload: Dict[str, Any], task: str) -> float:
	"""Get per-task success threshold if available, otherwise use global threshold."""
	if not isinstance(payload, dict):
		return 0.5

	# Check for per-task thresholds
	thresholds = payload.get("thresholds", {})
	if isinstance(thresholds, dict) and task in thresholds:
		return float(thresholds[task])

	# Fall back to global threshold
	threshold = payload.get("threshold")
	if isinstance(threshold, (int, float)):
		return float(threshold)

	# Default threshold
	return 0.5


					features[f"extra_{k}"] = float(v)
		except Exception:
			pass
	return features

def features_to_vector(features: Dict[str, float], keys: List[str]) -> List[float]:
	return [float(features.get(k, 0.0)) for k in keys]

def load_router_model(model_path: str) -> Optional[Dict[str, Any]]:
	"""Load a trained router model from disk."""
	try:
		import joblib  # type: ignore
		payload = joblib.load(model_path)
		return payload
	except Exception:
		return None

def get_task_success_threshold(payload: Dict[str, Any], task: str) -> float:
	"""Get per-task success threshold if available, otherwise use global threshold."""
	if not isinstance(payload, dict):
		return 0.5

	# Check for per-task thresholds
	thresholds = payload.get("thresholds", {})
	if isinstance(thresholds, dict) and task in thresholds:
		return float(thresholds[task])

	# Fall back to global threshold
	threshold = payload.get("threshold")
	if isinstance(threshold, (int, float)):
		return float(threshold)

	# Default threshold
	return 0.5


import logging
from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class Action:
	tool: str
	args: Dict

class TwoHeadActuator:
	"""
	Head-1: classify tool (wc|grep|head|tail).
	Head-2: choose argv template + fill slots {path,pattern,n}.
	"""

	def __init__(self, clf, slot_model, templates: Dict[str, List[str]]):
		self.clf = clf
		self.slot_model = slot_model
		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"

class HeuristicSlotFiller:
	"""Fill simple slots from obs/plan for known tools.
	Returns mapping for str.format templates.
	"""

	def fill(self, obs: Dict, plan: Dict, tool: str) -> Dict[str, str]:
		cwd = (obs.get("meta") or {}).get("cwd") or "."
		content = (obs or {}).get("content", "").lower()
		slots: Dict[str, str] = {"cwd": cwd}
		if tool == "wc":
			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":
			pattern = "error" if "error" in content else "INFO"
			flag = "-i" if "case-insensitive" in content or pattern.islower() else "-n"
			slots.update({"flag": flag, "pattern": pattern.upper() if flag == "-n" else pattern, "path": "logs/app.log"})
		elif tool == "head":
			n = "2" if "2" in content else "1"
			slots.update({"n": n, "path": "logs/app.log"})
		elif tool == "tail":
			n = "1" if "1" in content else "2"
			slots.update({"n": n, "path": "logs/app.log"})
		return slots

def default_cli_templates() -> Dict[str, List[str]]:
	return {
		"wc": ["wc {flag} {path}"],
		"grep": ["grep {flag} {pattern} {path}"],
		"head": ["head -n {n} {path}"],
		"tail": ["tail -n {n} {path}"],
	}


Action

import logging
from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class Action:
	tool: str
	args: Dict

class TwoHeadActuator:
	"""
	Head-1: classify tool (wc|grep|head|tail).
	Head-2: choose argv template + fill slots {path,pattern,n}.
	"""

	def __init__(self, clf, slot_model, templates: Dict[str, List[str]]):
		self.clf = clf
		self.slot_model = slot_model
		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:


TwoHeadActuator

import logging
from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class Action:
	tool: str
	args: Dict

class TwoHeadActuator:
	"""
	Head-1: classify tool (wc|grep|head|tail).
	Head-2: choose argv template + fill slots {path,pattern,n}.
	"""

	def __init__(self, clf, slot_model, templates: Dict[str, List[str]]):
		self.clf = clf
		self.slot_model = slot_model
		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"


HeuristicToolClassifier

		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"

class HeuristicSlotFiller:
	"""Fill simple slots from obs/plan for known tools.
	Returns mapping for str.format templates.
	"""

	def fill(self, obs: Dict, plan: Dict, tool: str) -> Dict[str, str]:
		cwd = (obs.get("meta") or {}).get("cwd") or "."
		content = (obs or {}).get("content", "").lower()
		slots: Dict[str, str] = {"cwd": cwd}
		if tool == "wc":
			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":


HeuristicSlotFiller

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"

class HeuristicSlotFiller:
	"""Fill simple slots from obs/plan for known tools.
	Returns mapping for str.format templates.
	"""

	def fill(self, obs: Dict, plan: Dict, tool: str) -> Dict[str, str]:
		cwd = (obs.get("meta") or {}).get("cwd") or "."
		content = (obs or {}).get("content", "").lower()
		slots: Dict[str, str] = {"cwd": cwd}
		if tool == "wc":
			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":
			pattern = "error" if "error" in content else "INFO"
			flag = "-i" if "case-insensitive" in content or pattern.islower() else "-n"
			slots.update({"flag": flag, "pattern": pattern.upper() if flag == "-n" else pattern, "path": "logs/app.log"})
		elif tool == "head":
			n = "2" if "2" in content else "1"
			slots.update({"n": n, "path": "logs/app.log"})
		elif tool == "tail":
			n = "1" if "1" in content else "2"
			slots.update({"n": n, "path": "logs/app.log"})
		return slots

def default_cli_templates() -> Dict[str, List[str]]:
	return {
		"wc": ["wc {flag} {path}"],
		"grep": ["grep {flag} {pattern} {path}"],
		"head": ["head -n {n} {path}"],
		"tail": ["tail -n {n} {path}"],
	}


			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":
			pattern = "error" if "error" in content else "INFO"
			flag = "-i" if "case-insensitive" in content or pattern.islower() else "-n"
			slots.update({"flag": flag, "pattern": pattern.upper() if flag == "-n" else pattern, "path": "logs/app.log"})
		elif tool == "head":
			n = "2" if "2" in content else "1"
			slots.update({"n": n, "path": "logs/app.log"})
		elif tool == "tail":
			n = "1" if "1" in content else "2"
			slots.update({"n": n, "path": "logs/app.log"})
		return slots

def default_cli_templates() -> Dict[str, List[str]]:
	return {
		"wc": ["wc {flag} {path}"],
		"grep": ["grep {flag} {pattern} {path}"],
		"head": ["head -n {n} {path}"],
		"tail": ["tail -n {n} {path}"],
	}


import logging
from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class Action:
	tool: str
	args: Dict

class TwoHeadActuator:
	"""
	Head-1: classify tool (wc|grep|head|tail).
	Head-2: choose argv template + fill slots {path,pattern,n}.
	"""

	def __init__(self, clf, slot_model, templates: Dict[str, List[str]]):
		self.clf = clf
		self.slot_model = slot_model
		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:


from typing import List, Dict, Optional

@dataclass
class Action:
	tool: str
	args: Dict

class TwoHeadActuator:
	"""
	Head-1: classify tool (wc|grep|head|tail).
	Head-2: choose argv template + fill slots {path,pattern,n}.
	"""

	def __init__(self, clf, slot_model, templates: Dict[str, List[str]]):
		self.clf = clf
		self.slot_model = slot_model
		self.templates = templates

	def predict_action(self, obs, plan) -> Optional[Action]:
		try:
			tool = self.clf.predict(obs, plan)
			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"


predict

			tpls = self.templates.get(tool, [])
			slots = self.slot_model.fill(obs, plan, tool)  # e.g., {"path":"docs/a.txt","pattern":"ERROR","n":"2"}
			for t in tpls:
				try:
					argv = t.format(**slots).split()
					if argv:
						cwd = (obs.get("meta") or {}).get("cwd") or "."
						return Action(tool=tool, args={"argv": argv, "cwd": cwd})
				except KeyError:
					continue
			return None
		except Exception:
			return None

class HeuristicToolClassifier:
	"""Very small heuristic classifier based on observation/plan text.
	Targets: wc | grep | head | tail
	"""

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"

class HeuristicSlotFiller:
	"""Fill simple slots from obs/plan for known tools.
	Returns mapping for str.format templates.
	"""

	def fill(self, obs: Dict, plan: Dict, tool: str) -> Dict[str, str]:
		cwd = (obs.get("meta") or {}).get("cwd") or "."
		content = (obs or {}).get("content", "").lower()
		slots: Dict[str, str] = {"cwd": cwd}
		if tool == "wc":
			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":


fill

	def predict(self, obs: Dict, plan: Dict) -> str:
		text = " ".join([
			str((obs or {}).get("content", "")),
			" ".join((plan or {}).get("subgoals", []) or []),
		]).lower()
		if "grep" in text or "error" in text or "find" in text:
			return "grep"
		if "head" in text or "first" in text:
			return "head"
		if "tail" in text or "last" in text:
			return "tail"
		# default to wc
		return "wc"

class HeuristicSlotFiller:
	"""Fill simple slots from obs/plan for known tools.
	Returns mapping for str.format templates.
	"""

	def fill(self, obs: Dict, plan: Dict, tool: str) -> Dict[str, str]:
		cwd = (obs.get("meta") or {}).get("cwd") or "."
		content = (obs or {}).get("content", "").lower()
		slots: Dict[str, str] = {"cwd": cwd}
		if tool == "wc":
			# choose chars/words/lines based on hint; default lines
			if "words" in content:
				slots.update({"flag": "-w", "path": "docs/a.txt"})
			elif "chars" in content or "characters" in content:
				slots.update({"flag": "-m", "path": "docs/a.txt"})
			else:
				slots.update({"flag": "-l", "path": "docs/a.txt"})
		elif tool == "grep":
			pattern = "error" if "error" in content else "INFO"
			flag = "-i" if "case-insensitive" in content or pattern.islower() else "-n"
			slots.update({"flag": flag, "pattern": pattern.upper() if flag == "-n" else pattern, "path": "logs/app.log"})
		elif tool == "head":
			n = "2" if "2" in content else "1"
			slots.update({"n": n, "path": "logs/app.log"})
		elif tool == "tail":
			n = "1" if "1" in content else "2"
			slots.update({"n": n, "path": "logs/app.log"})
		return slots

def default_cli_templates() -> Dict[str, List[str]]:
	return {
		"wc": ["wc {flag} {path}"],
		"grep": ["grep {flag} {pattern} {path}"],
		"head": ["head -n {n} {path}"],
		"tail": ["tail -n {n} {path}"],
	}


from __future__ import annotations

import logging
import json
from pathlib import Path
from typing import Any, Dict, Optional

def _safe(val: Any) -> Any:
	try:
		json.dumps(val, ensure_ascii=False)
		return val
	except Exception:
		return str(val)

def code_patch_apply(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.service import apply_code_patch  # type: ignore

	diff_text = str(args.get("diff", ""))
	branch = str(args.get("branch_name", "")) or None
	strict_arg = args.get("strict")
	res = apply_code_patch(
		repo_dir=str(repo_dir),
		diff_text=diff_text,
		branch_name=branch,
		strict=bool(strict_arg) if strict_arg is not None else None,
		max_files=int(args.get("max_files", 10) or 10),
		max_added=int(args.get("max_added", 400) or 400),
		max_deleted=int(args.get("max_deleted", 200) or 200),
		allow_paths=args.get("allow_paths", []),
		block_paths=args.get("block_paths", []),
	)
	if res.get("status") != "applied":
		# Best-effort sanitize
		kept: list[str] = []
		for line in diff_text.splitlines():
			if line.startswith(("diff --git", "index ", "--- ", "+++ ", "@@ ", "+", "-", " ")):
				kept.append(line)
			diff_clean = "\n".join(kept).strip()
			if diff_clean:
				res = apply_code_patch(
					repo_dir=str(repo_dir),
					diff_text=diff_clean,
					branch_name=branch,
					strict=bool(strict_arg) if strict_arg is not None else None,
					max_files=int(args.get("max_files", 10) or 10),
					max_added=int(args.get("max_added", 400) or 400),
					max_deleted=int(args.get("max_deleted", 200) or 200),
					allow_paths=args.get("allow_paths", []),
					block_paths=args.get("block_paths", []),
				)
	return res

def code_patch_revert(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
	pa = PatchActuator()
	pp = str(args.get("patch_path", ""))
	if not pp:
		return {"status": "noop"}
	return pa.revert_patch(pp, str(repo_dir))

def code_lint_run(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.linter import LinterTool  # type: ignore
	lt = LinterTool(str(repo_dir))
	paths = list(args.get("paths", []) or [])
	if not paths:
		paths = ["."]
	fl = lt.run_flake8(paths=paths) if hasattr(lt, "run_flake8") else {"available": False}
	my = lt.run_mypy(paths=paths) if hasattr(lt, "run_mypy") else {"available": False}
	return {"status": "ok", "flake8": _safe(fl), "mypy": _safe(my)}

def code_test_pytest(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.test_runner import TestRunner  # type: ignore
	tr = TestRunner(str(repo_dir))
	argv = list(args.get("args", []) or [])
	timeout = int(args.get("timeout", 600) or 600)
	env = args.get("env", {}) or {}
	res = tr.run_pytest(args=argv, timeout=timeout, env=env)
	return res

_DISPATCH = {
	"code.patch.apply": code_patch_apply,
	"code.patch.revert": code_patch_revert,
	"code.lint.run": code_lint_run,
	"code.test.pytest": code_test_pytest,
	"code.noop": lambda repo_dir, args: {"status": "ok"},
}

def execute_code_action(repo_dir: str, action: Dict[str, Any]) -> Dict[str, Any]:
	tool = str(action.get("tool", ""))
	args = action.get("args", {}) if isinstance(action.get("args"), dict) else {}
	f = _DISPATCH.get(tool)
	if f is None:
		return {"status": "error", "error": f"unknown tool: {tool}"}
	try:
		return f(repo_dir, args)
	except Exception as e:
		return {"status": "error", "error": str(e)}


from __future__ import annotations

import logging
import json
from pathlib import Path
from typing import Any, Dict, Optional

def _safe(val: Any) -> Any:
	try:
		json.dumps(val, ensure_ascii=False)
		return val
	except Exception:
		return str(val)

def code_patch_apply(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.service import apply_code_patch  # type: ignore

	diff_text = str(args.get("diff", ""))
	branch = str(args.get("branch_name", "")) or None
	strict_arg = args.get("strict")
	res = apply_code_patch(
		repo_dir=str(repo_dir),
		diff_text=diff_text,
		branch_name=branch,
		strict=bool(strict_arg) if strict_arg is not None else None,
		max_files=int(args.get("max_files", 10) or 10),
		max_added=int(args.get("max_added", 400) or 400),
		max_deleted=int(args.get("max_deleted", 200) or 200),
		allow_paths=args.get("allow_paths", []),
		block_paths=args.get("block_paths", []),
	)
	if res.get("status") != "applied":


from __future__ import annotations

import logging
import json
from pathlib import Path
from typing import Any, Dict, Optional

def _safe(val: Any) -> Any:
	try:
		json.dumps(val, ensure_ascii=False)
		return val
	except Exception:
		return str(val)

def code_patch_apply(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.service import apply_code_patch  # type: ignore

	diff_text = str(args.get("diff", ""))
	branch = str(args.get("branch_name", "")) or None
	strict_arg = args.get("strict")
	res = apply_code_patch(
		repo_dir=str(repo_dir),
		diff_text=diff_text,
		branch_name=branch,
		strict=bool(strict_arg) if strict_arg is not None else None,
		max_files=int(args.get("max_files", 10) or 10),
		max_added=int(args.get("max_added", 400) or 400),
		max_deleted=int(args.get("max_deleted", 200) or 200),
		allow_paths=args.get("allow_paths", []),
		block_paths=args.get("block_paths", []),
	)
	if res.get("status") != "applied":
		# Best-effort sanitize
		kept: list[str] = []
		for line in diff_text.splitlines():
			if line.startswith(("diff --git", "index ", "--- ", "+++ ", "@@ ", "+", "-", " ")):
				kept.append(line)
			diff_clean = "\n".join(kept).strip()
			if diff_clean:
				res = apply_code_patch(
					repo_dir=str(repo_dir),
					diff_text=diff_clean,
					branch_name=branch,
					strict=bool(strict_arg) if strict_arg is not None else None,
					max_files=int(args.get("max_files", 10) or 10),
					max_added=int(args.get("max_added", 400) or 400),
					max_deleted=int(args.get("max_deleted", 200) or 200),
					allow_paths=args.get("allow_paths", []),
					block_paths=args.get("block_paths", []),
				)
	return res

def code_patch_revert(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
	pa = PatchActuator()
	pp = str(args.get("patch_path", ""))
	if not pp:
		return {"status": "noop"}
	return pa.revert_patch(pp, str(repo_dir))

def code_lint_run(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.linter import LinterTool  # type: ignore
	lt = LinterTool(str(repo_dir))
	paths = list(args.get("paths", []) or [])
	if not paths:
		paths = ["."]
	fl = lt.run_flake8(paths=paths) if hasattr(lt, "run_flake8") else {"available": False}
	my = lt.run_mypy(paths=paths) if hasattr(lt, "run_mypy") else {"available": False}
	return {"status": "ok", "flake8": _safe(fl), "mypy": _safe(my)}


		kept: list[str] = []
		for line in diff_text.splitlines():
			if line.startswith(("diff --git", "index ", "--- ", "+++ ", "@@ ", "+", "-", " ")):
				kept.append(line)
			diff_clean = "\n".join(kept).strip()
			if diff_clean:
				res = apply_code_patch(
					repo_dir=str(repo_dir),
					diff_text=diff_clean,
					branch_name=branch,
					strict=bool(strict_arg) if strict_arg is not None else None,
					max_files=int(args.get("max_files", 10) or 10),
					max_added=int(args.get("max_added", 400) or 400),
					max_deleted=int(args.get("max_deleted", 200) or 200),
					allow_paths=args.get("allow_paths", []),
					block_paths=args.get("block_paths", []),
				)
	return res

def code_patch_revert(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
	pa = PatchActuator()
	pp = str(args.get("patch_path", ""))
	if not pp:
		return {"status": "noop"}
	return pa.revert_patch(pp, str(repo_dir))

def code_lint_run(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.linter import LinterTool  # type: ignore
	lt = LinterTool(str(repo_dir))
	paths = list(args.get("paths", []) or [])
	if not paths:
		paths = ["."]
	fl = lt.run_flake8(paths=paths) if hasattr(lt, "run_flake8") else {"available": False}
	my = lt.run_mypy(paths=paths) if hasattr(lt, "run_mypy") else {"available": False}
	return {"status": "ok", "flake8": _safe(fl), "mypy": _safe(my)}

def code_test_pytest(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.test_runner import TestRunner  # type: ignore
	tr = TestRunner(str(repo_dir))
	argv = list(args.get("args", []) or [])
	timeout = int(args.get("timeout", 600) or 600)
	env = args.get("env", {}) or {}
	res = tr.run_pytest(args=argv, timeout=timeout, env=env)


					branch_name=branch,
					strict=bool(strict_arg) if strict_arg is not None else None,
					max_files=int(args.get("max_files", 10) or 10),
					max_added=int(args.get("max_added", 400) or 400),
					max_deleted=int(args.get("max_deleted", 200) or 200),
					allow_paths=args.get("allow_paths", []),
					block_paths=args.get("block_paths", []),
				)
	return res

def code_patch_revert(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
	pa = PatchActuator()
	pp = str(args.get("patch_path", ""))
	if not pp:
		return {"status": "noop"}
	return pa.revert_patch(pp, str(repo_dir))

def code_lint_run(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.linter import LinterTool  # type: ignore
	lt = LinterTool(str(repo_dir))
	paths = list(args.get("paths", []) or [])
	if not paths:
		paths = ["."]
	fl = lt.run_flake8(paths=paths) if hasattr(lt, "run_flake8") else {"available": False}
	my = lt.run_mypy(paths=paths) if hasattr(lt, "run_mypy") else {"available": False}
	return {"status": "ok", "flake8": _safe(fl), "mypy": _safe(my)}

def code_test_pytest(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.test_runner import TestRunner  # type: ignore
	tr = TestRunner(str(repo_dir))
	argv = list(args.get("args", []) or [])
	timeout = int(args.get("timeout", 600) or 600)
	env = args.get("env", {}) or {}
	res = tr.run_pytest(args=argv, timeout=timeout, env=env)
	return res

_DISPATCH = {
	"code.patch.apply": code_patch_apply,
	"code.patch.revert": code_patch_revert,
	"code.lint.run": code_lint_run,
	"code.test.pytest": code_test_pytest,
	"code.noop": lambda repo_dir, args: {"status": "ok"},
}


def code_patch_revert(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.core.actuator.patch_actuator import PatchActuator  # type: ignore
	pa = PatchActuator()
	pp = str(args.get("patch_path", ""))
	if not pp:
		return {"status": "noop"}
	return pa.revert_patch(pp, str(repo_dir))

def code_lint_run(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.linter import LinterTool  # type: ignore
	lt = LinterTool(str(repo_dir))
	paths = list(args.get("paths", []) or [])
	if not paths:
		paths = ["."]
	fl = lt.run_flake8(paths=paths) if hasattr(lt, "run_flake8") else {"available": False}
	my = lt.run_mypy(paths=paths) if hasattr(lt, "run_mypy") else {"available": False}
	return {"status": "ok", "flake8": _safe(fl), "mypy": _safe(my)}

def code_test_pytest(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.test_runner import TestRunner  # type: ignore
	tr = TestRunner(str(repo_dir))
	argv = list(args.get("args", []) or [])
	timeout = int(args.get("timeout", 600) or 600)
	env = args.get("env", {}) or {}
	res = tr.run_pytest(args=argv, timeout=timeout, env=env)
	return res

_DISPATCH = {
	"code.patch.apply": code_patch_apply,
	"code.patch.revert": code_patch_revert,
	"code.lint.run": code_lint_run,
	"code.test.pytest": code_test_pytest,
	"code.noop": lambda repo_dir, args: {"status": "ok"},
}

def execute_code_action(repo_dir: str, action: Dict[str, Any]) -> Dict[str, Any]:
	tool = str(action.get("tool", ""))
	args = action.get("args", {}) if isinstance(action.get("args"), dict) else {}
	f = _DISPATCH.get(tool)
	if f is None:
		return {"status": "error", "error": f"unknown tool: {tool}"}
	try:
		return f(repo_dir, args)
	except Exception as e:


def code_test_pytest(repo_dir: str, args: Dict[str, Any]) -> Dict[str, Any]:
	from agi_dw.tools.test_runner import TestRunner  # type: ignore
	tr = TestRunner(str(repo_dir))
	argv = list(args.get("args", []) or [])
	timeout = int(args.get("timeout", 600) or 600)
	env = args.get("env", {}) or {}
	res = tr.run_pytest(args=argv, timeout=timeout, env=env)
	return res

_DISPATCH = {
	"code.patch.apply": code_patch_apply,
	"code.patch.revert": code_patch_revert,
	"code.lint.run": code_lint_run,
	"code.test.pytest": code_test_pytest,
	"code.noop": lambda repo_dir, args: {"status": "ok"},
}

def execute_code_action(repo_dir: str, action: Dict[str, Any]) -> Dict[str, Any]:
	tool = str(action.get("tool", ""))
	args = action.get("args", {}) if isinstance(action.get("args"), dict) else {}
	f = _DISPATCH.get(tool)
	if f is None:
		return {"status": "error", "error": f"unknown tool: {tool}"}
	try:
		return f(repo_dir, args)
	except Exception as e:
		return {"status": "error", "error": str(e)}


import logging
import os
from pathlib import Path
from typing import Any, Dict

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

DEFAULT_PATH = Path(__file__).resolve().parents[2] / "config" / "default.yaml"

def load_config(path: str | None = None) -> Dict[str, Any]:
	if yaml is None:
		raise RuntimeError("pyyaml not installed. pip install pyyaml")
	file_path = Path(path) if path else DEFAULT_PATH
	cfg: Dict[str, Any] = {}
	if file_path.exists():
		with open(file_path, "r", encoding="utf-8") as f:
			cfg = yaml.safe_load(f) or {}
	# Env overrides (simple flat mapping: SECTION__KEY=value)
	for key, value in os.environ.items():
		if "__" in key:
			section, sub = key.split("__", 1)
			section = section.lower()
			sub = sub.lower()
			cfg.setdefault(section, {})
			cfg[section][sub] = value
	return cfg


from __future__ import annotations
import logging

from dataclasses import dataclass
from typing import Dict, List, Literal, TypedDict

Decision = Literal["approved", "denied", "modified", "deferred", "expired"]

class ActionPayload(TypedDict, total=False):
	id: str
	ts: str
	actor: str
	kind: Literal["code.patch", "cli.op"]
	preview_path: str
	risk: float
	allowlist_hit: bool
	files: List[str]
	added: int
	deleted: int
	signature: str

@dataclass
class ApprovalDecision:
	id: str
	decision: Decision
	note: str | None = None


ApprovalDecision

Decision = Literal["approved", "denied", "modified", "deferred", "expired"]

class ActionPayload(TypedDict, total=False):
	id: str
	ts: str
	actor: str
	kind: Literal["code.patch", "cli.op"]
	preview_path: str
	risk: float
	allowlist_hit: bool
	files: List[str]
	added: int
	deleted: int
	signature: str

@dataclass
class ApprovalDecision:
	id: str
	decision: Decision
	note: str | None = None


from __future__ import annotations

import logging
import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone

@dataclass
class ApprovalItem:
	id: str
	ts: str
	status: str  # pending | approved | denied | modified | deferred
	kind: str  # code.patch | cli.op
	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:
			for line in self.queue_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("status", "pending")) == "pending":
					pending.append(obj)
		except Exception:
			pending = []
		return pending

	def write_decision(self, decision: Dict[str, Any]) -> None:
		with self.decisions_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(decision, ensure_ascii=False) + "\n")

	def wait_for_decision(self, item_id: str, timeout_sec: int = 60, poll_ms: int = 500) -> Optional[Dict[str, Any]]:
		end = time.time() + max(1, int(timeout_sec))
		pos = 0
		while time.time() < end:
			try:
				with self.decisions_path.open("r", encoding="utf-8") as f:
					f.seek(pos)
					for line in f:
						pos = f.tell()
						if not line.strip():
							continue
						obj = json.loads(line)
						if str(obj.get("id")) == item_id:
							return obj
			except Exception:
				pass
			time.sleep(max(0.001, float(poll_ms) / 1000.0))
		return None

	# --- New helpers and maintenance APIs ---

	def _parse_ts(self, ts_str: str) -> float:
		"""Parse TS string like 20250101T000000Z or ISO8601 into epoch seconds."""
		try:
			# Fast-path: canonical "%Y%m%dT%H%M%SZ"
			return datetime.strptime(str(ts_str), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc).timestamp()
		except Exception:
			pass
		try:
			# Fallback to fromisoformat variants
			s = str(ts_str).replace("Z", "+00:00")
			return datetime.fromisoformat(s).timestamp()
		except Exception:
			return 0.0

	def _has_decision(self, item_id: str) -> bool:
		"""Return True if a decision exists for given id."""
		try:
			for line in self.decisions_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("id")) == str(item_id):
					return True
		except Exception:
			return False
		return False

	def update_status(self, item_id: str, decision: str, note: str | None = None, actor: str = "system") -> Dict[str, Any]:
		"""Append a decision record for an item. Keeps queue JSONL append-only.

		Returns the decision object written.
		"""
		# Build record
		rec: Dict[str, Any] = {
			"id": str(item_id),
			"decision": str(decision),
			"note": str(note or ""),
			"ts": datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
		}
		# Append to decisions log
		self.write_decision(rec)
		# Append to immutable audit log if available
		try:
			try:
				from agi_dw.core.hitl.audit_log import AuditLog  # type: ignore
			except ModuleNotFoundError:
				import sys as _sys  # type: ignore
				_root = Path(__file__).resolve().parents[2]
				for p in (str(_root), str(_root / "agi_dw")):
					if p not in _sys.path:
						_sys.path.insert(0, p)
				from agi_dw.core.hitl.audit_log import AuditLog  # type: ignore
			AuditLog(Path(__file__).resolve().parents[2]).append(action=f"decision.{decision}", actor=str(actor), data=rec)
		except Exception:
			pass
		return rec

	def sweep_expired(self, ttl_sec: int = 600, actor: str = "sweeper") -> List[Dict[str, Any]]:
		"""Mark pending items older than ttl as expired by writing decisions.

		This does not rewrite the queue; it appends to decisions.jsonl only.
		Returns a list of decision objects written.
		"""
		written: List[Dict[str, Any]] = []
		try:
			now = time.time()
			pend = self.read_pending()
			for obj in pend:
				try:
					id_ = str(obj.get("id", ""))
					if not id_ or self._has_decision(id_):
						continue
					ts_str = str(obj.get("ts", ""))
					age = now - float(self._parse_ts(ts_str))
					if ttl_sec > 0 and age >= float(ttl_sec):
						dec = self.update_status(id_, "expired", note="ttl_expired", actor=actor)
						written.append(dec)
				except Exception:
					continue
		except Exception:
			return written
		return written


ApprovalItem

from __future__ import annotations

import logging
import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone

@dataclass
class ApprovalItem:
	id: str
	ts: str
	status: str  # pending | approved | denied | modified | deferred
	kind: str  # code.patch | cli.op
	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:


ApprovalQueue

import logging
import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone

@dataclass
class ApprovalItem:
	id: str
	ts: str
	status: str  # pending | approved | denied | modified | deferred
	kind: str  # code.patch | cli.op
	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:
			for line in self.queue_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("status", "pending")) == "pending":
					pending.append(obj)
		except Exception:
			pending = []
		return pending

	def write_decision(self, decision: Dict[str, Any]) -> None:
		with self.decisions_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(decision, ensure_ascii=False) + "\n")

	def wait_for_decision(self, item_id: str, timeout_sec: int = 60, poll_ms: int = 500) -> Optional[Dict[str, Any]]:
		end = time.time() + max(1, int(timeout_sec))
		pos = 0
		while time.time() < end:
			try:
				with self.decisions_path.open("r", encoding="utf-8") as f:
					f.seek(pos)
					for line in f:
						pos = f.tell()
						if not line.strip():
							continue
						obj = json.loads(line)
						if str(obj.get("id")) == item_id:
							return obj
			except Exception:
				pass
			time.sleep(max(0.001, float(poll_ms) / 1000.0))
		return None

	# --- New helpers and maintenance APIs ---

	def _parse_ts(self, ts_str: str) -> float:
		"""Parse TS string like 20250101T000000Z or ISO8601 into epoch seconds."""
		try:
			# Fast-path: canonical "%Y%m%dT%H%M%SZ"
			return datetime.strptime(str(ts_str), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc).timestamp()
		except Exception:
			pass
		try:
			# Fallback to fromisoformat variants
			s = str(ts_str).replace("Z", "+00:00")
			return datetime.fromisoformat(s).timestamp()
		except Exception:
			return 0.0

	def _has_decision(self, item_id: str) -> bool:
		"""Return True if a decision exists for given id."""
		try:
			for line in self.decisions_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("id")) == str(item_id):
					return True
		except Exception:
			return False
		return False

	def update_status(self, item_id: str, decision: str, note: str | None = None, actor: str = "system") -> Dict[str, Any]:
		"""Append a decision record for an item. Keeps queue JSONL append-only.

		Returns the decision object written.
		"""
		# Build record
		rec: Dict[str, Any] = {
			"id": str(item_id),
			"decision": str(decision),
			"note": str(note or ""),
			"ts": datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
		}
		# Append to decisions log
		self.write_decision(rec)
		# Append to immutable audit log if available
		try:
			try:
				from agi_dw.core.hitl.audit_log import AuditLog  # type: ignore
			except ModuleNotFoundError:
				import sys as _sys  # type: ignore
				_root = Path(__file__).resolve().parents[2]
				for p in (str(_root), str(_root / "agi_dw")):
					if p not in _sys.path:
						_sys.path.insert(0, p)
				from agi_dw.core.hitl.audit_log import AuditLog  # type: ignore
			AuditLog(Path(__file__).resolve().parents[2]).append(action=f"decision.{decision}", actor=str(actor), data=rec)
		except Exception:
			pass
		return rec

	def sweep_expired(self, ttl_sec: int = 600, actor: str = "sweeper") -> List[Dict[str, Any]]:
		"""Mark pending items older than ttl as expired by writing decisions.

		This does not rewrite the queue; it appends to decisions.jsonl only.
		Returns a list of decision objects written.
		"""
		written: List[Dict[str, Any]] = []
		try:
			now = time.time()
			pend = self.read_pending()
			for obj in pend:
				try:
					id_ = str(obj.get("id", ""))
					if not id_ or self._has_decision(id_):
						continue
					ts_str = str(obj.get("ts", ""))
					age = now - float(self._parse_ts(ts_str))
					if ttl_sec > 0 and age >= float(ttl_sec):
						dec = self.update_status(id_, "expired", note="ttl_expired", actor=actor)
						written.append(dec)
				except Exception:
					continue
		except Exception:
			return written
		return written


import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone

@dataclass
class ApprovalItem:
	id: str
	ts: str
	status: str  # pending | approved | denied | modified | deferred
	kind: str  # code.patch | cli.op
	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:
			for line in self.queue_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("status", "pending")) == "pending":
					pending.append(obj)
		except Exception:
			pending = []
		return pending

	def write_decision(self, decision: Dict[str, Any]) -> None:
		with self.decisions_path.open("a", encoding="utf-8") as f:


write

	id: str
	ts: str
	status: str  # pending | approved | denied | modified | deferred
	kind: str  # code.patch | cli.op
	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:
			for line in self.queue_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("status", "pending")) == "pending":
					pending.append(obj)
		except Exception:
			pending = []
		return pending

	def write_decision(self, decision: Dict[str, Any]) -> None:
		with self.decisions_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(decision, ensure_ascii=False) + "\n")

	def wait_for_decision(self, item_id: str, timeout_sec: int = 60, poll_ms: int = 500) -> Optional[Dict[str, Any]]:
		end = time.time() + max(1, int(timeout_sec))


	preview_path: str
	repo: str
	meta: Dict[str, Any]

class ApprovalQueue:
	def __init__(self, root_dir: str | Path) -> None:
		root = Path(root_dir)
		self.queue_path = root / "data" / "hitl" / "queue.jsonl"
		self.decisions_path = root / "data" / "hitl" / "decisions.jsonl"
		self.queue_path.parent.mkdir(parents=True, exist_ok=True)
		if not self.queue_path.exists():
			self.queue_path.write_text("", encoding="utf-8")
		if not self.decisions_path.exists():
			self.decisions_path.write_text("", encoding="utf-8")

	def write(self, item: ApprovalItem) -> None:
		with self.queue_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(asdict(item), ensure_ascii=False) + "\n")

	def read_pending(self) -> List[Dict[str, Any]]:
		pending: List[Dict[str, Any]] = []
		try:
			for line in self.queue_path.read_text(encoding="utf-8").splitlines():
				if not line.strip():
					continue
				obj = json.loads(line)
				if str(obj.get("status", "pending")) == "pending":
					pending.append(obj)
		except Exception:
			pending = []
		return pending

	def write_decision(self, decision: Dict[str, Any]) -> None:
		with self.decisions_path.open("a", encoding="utf-8") as f:
			f.write(json.dumps(decision, ensure_ascii=False) + "\n")

	def wait_for_decision(self, item_id: str, timeout_sec: int = 60, poll_ms: int = 500) -> Optional[Dict[str, Any]]:
		end = time.time() + max(1, int(timeout_sec))
		pos = 0
		while time.time() < end:
			try:
				with self.decisions_path.open("r", encoding="utf-8") as f:
					f.seek(pos)
					for line in f:
						pos = f.tell()
						if not line.strip():
							continue
						obj = json.loads(line)
						if str(obj.get("id")) == item_id:
							return obj
			except Exception: