core.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """
  2. lambdagent_guard.core — Shared guard infrastructure (I10).
  3. Provides:
  4. - GuardConfig: unified config for all framework guards
  5. - Compile-time checks: type safety, cost prediction, store independence
  6. - Runtime hooks: cost accumulation, loop detection, budget enforcement
  7. """
  8. from __future__ import annotations
  9. import hashlib
  10. import time
  11. from dataclasses import dataclass, field
  12. from typing import Any, Callable, Dict, List, Optional
  13. # ============================================================
  14. # Exceptions
  15. # ============================================================
  16. class CostBudgetExceeded(RuntimeError):
  17. """Execution cost exceeded the configured budget ceiling."""
  18. def __init__(self, spent: float, budget: float, step: int = 0):
  19. self.spent = spent
  20. self.budget = budget
  21. self.step = step
  22. super().__init__(
  23. f"Cost budget exceeded at step {step}: "
  24. f"${spent:.4f} > ${budget:.4f}"
  25. )
  26. class InfiniteLoopDetected(RuntimeError):
  27. """Repeated identical states suggest no forward progress."""
  28. def __init__(self, message: str, step: int = 0):
  29. self.step = step
  30. super().__init__(message)
  31. class StoreConflictError(RuntimeError):
  32. """Parallel agents write to overlapping state keys."""
  33. pass
  34. # ============================================================
  35. # Configuration
  36. # ============================================================
  37. @dataclass
  38. class GuardConfig:
  39. """Unified guard configuration for all framework wrappers."""
  40. cost_budget: float = float("inf") # Max cost in USD
  41. type_check: bool = True # T-Compose checking at wrap time
  42. loop_detection: bool = True # Repeated-state detection
  43. loop_window: int = 5 # Recent steps to check
  44. loop_threshold: int = 3 # Repeats before alert
  45. parallel_safety: bool = False # Store independence check
  46. terminate_check: bool = True # Verify termination exists
  47. cost_alert: Optional[Callable[[float], None]] = None # Callback on cost
  48. empty_message_detection: bool = False # AutoGen #108 blank-msg detection
  49. terminate_robustness: bool = False # Don't rely on exact string match
  50. @dataclass
  51. class GuardedResult:
  52. """Result from a guarded execution."""
  53. result: Any
  54. total_cost_usd: float = 0.0
  55. total_tokens: int = 0
  56. steps: int = 0
  57. loop_detected: bool = False
  58. budget_remaining: float = float("inf")
  59. # ============================================================
  60. # Compile-Time Checks
  61. # ============================================================
  62. def run_compile_checks(config: dict, guard_cfg: GuardConfig) -> List[str]:
  63. """
  64. Run compile-time static checks on extracted config.
  65. Returns list of warning/error messages (empty = all clear).
  66. """
  67. warnings = []
  68. # Lint check
  69. try:
  70. from lambdagent.fromconfig.lint import lint_config
  71. results = lint_config(config)
  72. errors = [r for r in results if r.level == "ERROR"]
  73. if errors:
  74. for e in errors[:5]:
  75. warnings.append(f"[LINT {e.rule}] {e.message}")
  76. except Exception:
  77. pass
  78. # Cost prediction
  79. if guard_cfg.cost_budget < float("inf"):
  80. try:
  81. from lambdagent.cost_grade import estimate_cost
  82. from lambdagent.fromconfig import from_config
  83. import tempfile, yaml, os
  84. with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
  85. yaml.dump(config, f, allow_unicode=True)
  86. tmp = f.name
  87. try:
  88. term = from_config(tmp)
  89. grade = estimate_cost(term)
  90. if grade.money > guard_cfg.cost_budget:
  91. warnings.append(
  92. f"[COST] Predicted worst-case ${grade.money:.2f} exceeds "
  93. f"budget ${guard_cfg.cost_budget:.2f}"
  94. )
  95. if grade.probability < 0.05:
  96. warnings.append(
  97. f"[COST] Success probability {grade.probability:.1%} is critically low"
  98. )
  99. finally:
  100. os.unlink(tmp)
  101. except Exception:
  102. pass
  103. # Terminate check
  104. if guard_cfg.terminate_check:
  105. tools = config.get("mcp", {}).get("localTools", [])
  106. agent_type = config.get("type", "simple")
  107. if agent_type == "react" and "terminate" not in tools:
  108. warnings.append(
  109. "[LINT L004] No terminate tool in ReAct loop — "
  110. "forced truncation at maxSteps, not clean exit"
  111. )
  112. return warnings
  113. # ============================================================
  114. # Runtime Monitor
  115. # ============================================================
  116. class RuntimeMonitor:
  117. """
  118. Tracks cost and state during execution.
  119. Injected as a hook into framework execution loops.
  120. """
  121. def __init__(self, guard_cfg: GuardConfig):
  122. self.cfg = guard_cfg
  123. self.total_cost = 0.0
  124. self.total_tokens = 0
  125. self.step_count = 0
  126. self._state_hashes: List[str] = []
  127. self._empty_msg_count = 0
  128. def on_step(self, step_info: Dict[str, Any]):
  129. """Called before/after each LLM/tool step."""
  130. self.step_count += 1
  131. # Cost tracking
  132. tokens = step_info.get("tokens", 0)
  133. cost = step_info.get("cost_usd", tokens * 0.000003) # default: ~sonnet rate
  134. self.total_tokens += tokens
  135. self.total_cost += cost
  136. if self.cfg.cost_alert:
  137. self.cfg.cost_alert(self.total_cost)
  138. # Budget enforcement
  139. if self.total_cost > self.cfg.cost_budget:
  140. raise CostBudgetExceeded(self.total_cost, self.cfg.cost_budget, self.step_count)
  141. # Loop detection
  142. if self.cfg.loop_detection:
  143. state_str = str(step_info.get("output", ""))[:500]
  144. h = hashlib.md5(state_str.encode()).hexdigest()[:12]
  145. self._state_hashes.append(h)
  146. if len(self._state_hashes) > self.cfg.loop_window:
  147. self._state_hashes = self._state_hashes[-self.cfg.loop_window:]
  148. if self._state_hashes.count(h) >= self.cfg.loop_threshold:
  149. raise InfiniteLoopDetected(
  150. f"Same output repeated {self._state_hashes.count(h)} times "
  151. f"in last {self.cfg.loop_window} steps at step {self.step_count}",
  152. self.step_count,
  153. )
  154. # Empty message detection (AutoGen #108)
  155. if self.cfg.empty_message_detection:
  156. output = str(step_info.get("output", "")).strip()
  157. if len(output) < 5:
  158. self._empty_msg_count += 1
  159. if self._empty_msg_count >= 3:
  160. raise InfiniteLoopDetected(
  161. f"Empty/near-empty messages detected {self._empty_msg_count} "
  162. f"consecutive times (cf. AutoGen #108)",
  163. self.step_count,
  164. )
  165. else:
  166. self._empty_msg_count = 0
  167. def result(self, value: Any) -> GuardedResult:
  168. """Build final GuardedResult."""
  169. return GuardedResult(
  170. result=value,
  171. total_cost_usd=self.total_cost,
  172. total_tokens=self.total_tokens,
  173. steps=self.step_count,
  174. budget_remaining=max(0, self.cfg.cost_budget - self.total_cost),
  175. )