backend.py 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """Backend config loader and command builder."""
  2. from __future__ import annotations
  3. import os
  4. import re
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from typing import Any
  8. import yaml
  9. @dataclass
  10. class Backend:
  11. name: str
  12. cli: str
  13. args: list[str]
  14. required_env: list[str]
  15. hooks: dict[str, list[str]]
  16. shutdown: str
  17. idle: dict[str, Any]
  18. startup_timeout: int
  19. terminal: dict[str, int]
  20. session_logs: dict[str, str]
  21. turn_timeout: int | None = None
  22. busy_pattern: str = ""
  23. max_busy_seconds: int = 1800
  24. def build_command(self, workdir: str) -> list[str]:
  25. resolved = [_interpolate_env(arg) for arg in self.args]
  26. return [self.cli, *resolved]
  27. def validate_env(self) -> None:
  28. missing = [v for v in self.required_env if not os.environ.get(v)]
  29. if missing:
  30. raise OSError(
  31. f"Missing required environment variables for {self.name} backend: "
  32. + ", ".join(missing)
  33. )
  34. def is_ready_line(self, line: str) -> bool:
  35. pattern = self.idle.get("ready_pattern", "")
  36. return bool(re.search(pattern, line))
  37. def is_busy_line(self, line: str) -> bool:
  38. if not self.busy_pattern:
  39. return False
  40. return bool(re.search(self.busy_pattern, line))
  41. @property
  42. def quiescence_seconds(self) -> float:
  43. return self.idle.get("quiescence_seconds", 5)
  44. @property
  45. def cols(self) -> int:
  46. return self.terminal.get("cols", 200)
  47. @property
  48. def rows(self) -> int:
  49. return self.terminal.get("rows", 50)
  50. @property
  51. def model(self) -> str | None:
  52. """Model name from args (looks for --model or -m flag)."""
  53. for i, arg in enumerate(self.args):
  54. if arg in ("--model", "-m") and i + 1 < len(self.args):
  55. return self.args[i + 1]
  56. return None
  57. @property
  58. def family(self) -> str:
  59. """Normalize backend name to a family for log-dir / normalizer dispatch."""
  60. for fam in ("claude", "codex", "gemini"):
  61. if self.name == fam or self.name.startswith(f"{fam}-"):
  62. return fam
  63. return "other"
  64. def load_backend(name: str, backends_dir: Path) -> Backend:
  65. path = backends_dir / f"{name}.yaml"
  66. if not path.exists():
  67. raise FileNotFoundError(f"Backend config not found: {path}")
  68. with open(path) as f:
  69. data = yaml.safe_load(f)
  70. return Backend(
  71. name=data["name"],
  72. cli=data["cli"],
  73. args=data.get("args", []),
  74. required_env=data.get("required_env", []),
  75. hooks=data.get("hooks", {"pre_run": [], "post_run": []}),
  76. shutdown=data.get("shutdown", "/exit"),
  77. idle=data.get("idle", {}),
  78. startup_timeout=data.get("startup_timeout", 30),
  79. terminal=data.get("terminal", {"cols": 200, "rows": 50}),
  80. session_logs=data.get("session_logs", {}),
  81. turn_timeout=data.get("turn_timeout"),
  82. busy_pattern=data.get("busy_pattern", ""),
  83. max_busy_seconds=data.get("max_busy_seconds", 1800),
  84. )
  85. def _interpolate_env(value: str) -> str:
  86. def replacer(match: re.Match[str]) -> str:
  87. var = match.group(1)
  88. val = os.environ.get(var)
  89. if val is None:
  90. raise OSError(f"Environment variable {var} not set")
  91. return val
  92. return re.sub(r"\$\{(\w+)\}", replacer, value)