Coverage for src / harnessutils / config.py: 84%

164 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-18 08:30 -0600

1"""Configuration schema for harness-utils.""" 

2 

3from dataclasses import dataclass, field 

4from pathlib import Path 

5from typing import Any 

6 

7 

8@dataclass 

9class TruncationConfig: 

10 """Configuration for Tier 1: Output truncation.""" 

11 

12 max_lines: int = 2000 

13 max_bytes: int = 50 * 1024 # 50KB 

14 direction: str = "head" # "head" or "tail" 

15 

16 # Phase 2: Content-aware truncation 

17 max_tokens: int = 2000 # Token-based limit 

18 use_content_aware: bool = True # Enable content-aware truncation 

19 preserve_errors: bool = True # Keep all errors/warnings in logs 

20 json_array_limit: int = 10 # Keep first/last N items in JSON arrays 

21 stacktrace_frame_limit: int = 20 # Keep top/bottom N frames in stacktraces 

22 

23 def validate(self) -> None: 

24 """Validate truncation configuration. 

25 

26 Raises: 

27 ConfigurationError: If configuration is invalid 

28 """ 

29 from harnessutils.exceptions import ConfigurationError 

30 

31 if self.max_lines <= 0: 

32 raise ConfigurationError( 

33 f"max_lines must be positive, got {self.max_lines}", 

34 "Set max_lines to a positive integer (e.g., 2000)", 

35 ) 

36 

37 if self.max_bytes <= 0: 

38 raise ConfigurationError( 

39 f"max_bytes must be positive, got {self.max_bytes}", 

40 "Set max_bytes to a positive integer (e.g., 50000)", 

41 ) 

42 

43 if self.max_tokens <= 0: 

44 raise ConfigurationError( 

45 f"max_tokens must be positive, got {self.max_tokens}", 

46 "Set max_tokens to a positive integer (e.g., 2000)", 

47 ) 

48 

49 if self.direction not in ["head", "tail"]: 

50 raise ConfigurationError( 

51 f"direction must be 'head' or 'tail', got '{self.direction}'", 

52 "Set direction to either 'head' (keep beginning) or 'tail' (keep end)", 

53 ) 

54 

55 if self.json_array_limit <= 0: 

56 raise ConfigurationError( 

57 f"json_array_limit must be positive, got {self.json_array_limit}", 

58 "Set json_array_limit to a positive integer (e.g., 10)", 

59 ) 

60 

61 if self.stacktrace_frame_limit <= 0: 

62 raise ConfigurationError( 

63 f"stacktrace_frame_limit must be positive, got {self.stacktrace_frame_limit}", 

64 "Set stacktrace_frame_limit to a positive integer (e.g., 20)", 

65 ) 

66 

67 

68@dataclass 

69class PruningConfig: 

70 """Configuration for Tier 2: Selective pruning.""" 

71 

72 prune_protect: int = 40_000 # Keep recent 40K tokens 

73 prune_minimum: int = 20_000 # Only prune if saves 20K+ tokens 

74 protect_turns: int = 2 # Protect last 2 turns 

75 protected_tools: list[str] = field( 

76 default_factory=lambda: ["skill_execution", "subtask_invocation"] 

77 ) 

78 

79 # Importance scoring (Phase 1.2) 

80 use_importance_scoring: bool = True # Enable smart pruning 

81 recency_weight: float = 1.0 # Weight for recency score 

82 size_weight: float = -0.5 # Weight for size penalty (negative) 

83 semantic_weight: float = 2.0 # Weight for semantic importance 

84 tool_priority_weight: float = 1.5 # Weight for tool type priority 

85 recency_decay: float = 0.1 # Exponential decay rate per turn 

86 

87 # Tool importance map (higher = more important) 

88 tool_importance: dict[str, float] = field( 

89 default_factory=lambda: { 

90 "read": 50.0, 

91 "write": 100.0, # Code changes are important 

92 "edit": 100.0, 

93 "grep": 30.0, # Search results often repetitive 

94 "glob": 30.0, 

95 "bash": 70.0, 

96 "skill_execution": 150.0, # Complex operations 

97 "subtask_invocation": 150.0, 

98 "error": 200.0, # Critical for debugging 

99 } 

100 ) 

101 

102 # Semantic boost scores 

103 error_boost: float = 500.0 # Boost for outputs with errors 

104 warning_boost: float = 200.0 # Boost for warnings 

105 user_requested_boost: float = 300.0 # User explicitly asked for this 

106 

107 # Deduplication (Phase 1.3) 

108 detect_duplicates: bool = True # Enable duplicate detection 

109 similarity_threshold: float = 0.8 # Similarity threshold (0.0-1.0) 

110 duplicate_lookback: int = 20 # Check last N outputs for duplicates 

111 

112 def validate(self) -> None: 

113 """Validate pruning configuration. 

114 

115 Raises: 

116 ConfigurationError: If configuration is invalid 

117 """ 

118 from harnessutils.exceptions import ConfigurationError 

119 

120 if self.prune_minimum >= self.prune_protect: 

121 raise ConfigurationError( 

122 f"prune_minimum ({self.prune_minimum}) must be < " 

123 f"prune_protect ({self.prune_protect})", 

124 "Set prune_minimum to a value less than prune_protect. " 

125 "Example: prune_minimum=20000, prune_protect=40000", 

126 ) 

127 

128 if self.protect_turns < 0: 

129 raise ConfigurationError( 

130 f"protect_turns must be >= 0, got {self.protect_turns}", 

131 "Set protect_turns to 0 or more (e.g., 2 to protect last 2 turns)", 

132 ) 

133 

134 if self.similarity_threshold < 0.0 or self.similarity_threshold > 1.0: 

135 raise ConfigurationError( 

136 f"similarity_threshold must be between 0.0 and 1.0, " 

137 f"got {self.similarity_threshold}", 

138 "Set similarity_threshold to a value between 0.0 (loose matching) " 

139 "and 1.0 (exact matching). Try 0.8 for most cases.", 

140 ) 

141 

142 if self.duplicate_lookback <= 0: 

143 raise ConfigurationError( 

144 f"duplicate_lookback must be positive, got {self.duplicate_lookback}", 

145 "Set duplicate_lookback to a positive integer (e.g., 20)", 

146 ) 

147 

148 # Check weights are non-negative 

149 for weight_name in [ 

150 "recency_weight", 

151 "semantic_weight", 

152 "tool_priority_weight", 

153 "error_boost", 

154 "warning_boost", 

155 "user_requested_boost", 

156 ]: 

157 weight = getattr(self, weight_name) 

158 if weight < 0: 

159 raise ConfigurationError( 

160 f"{weight_name} must be >= 0, got {weight}", 

161 f"Set {weight_name} to a non-negative value", 

162 ) 

163 

164 

165@dataclass 

166class TokenConfig: 

167 """Configuration for token estimation.""" 

168 

169 chars_per_token: int = 4 

170 

171 

172@dataclass 

173class ModelLimitsConfig: 

174 """Configuration for model limits.""" 

175 

176 default_context_limit: int = 200_000 

177 default_output_limit: int = 8_192 

178 

179 def validate(self) -> None: 

180 """Validate model limits configuration. 

181 

182 Raises: 

183 ConfigurationError: If configuration is invalid 

184 """ 

185 from harnessutils.exceptions import ConfigurationError 

186 

187 if self.default_context_limit <= 0: 

188 raise ConfigurationError( 

189 f"default_context_limit must be positive, got {self.default_context_limit}", 

190 "Set default_context_limit to your model's context window size (e.g., 200000)", 

191 ) 

192 

193 if self.default_output_limit <= 0: 

194 raise ConfigurationError( 

195 f"default_output_limit must be positive, got {self.default_output_limit}", 

196 "Set default_output_limit to your model's max output tokens (e.g., 8192)", 

197 ) 

198 

199 if self.default_output_limit >= self.default_context_limit: 

200 raise ConfigurationError( 

201 f"default_output_limit ({self.default_output_limit}) must be < " 

202 f"default_context_limit ({self.default_context_limit})", 

203 "Set default_output_limit to less than default_context_limit. " 

204 "Example: context_limit=200000, output_limit=8192", 

205 ) 

206 

207 

208@dataclass 

209class StorageConfig: 

210 """Configuration for storage layer.""" 

211 

212 base_path: Path = field(default_factory=lambda: Path("data")) 

213 retention_days: int = 7 # For truncated outputs 

214 

215 

216@dataclass 

217class SummarizationConfig: 

218 """Configuration for Tier 3: Summarization. 

219 

220 Note: Model selection is user-controlled. Set differential_model and full_model 

221 to match your LLM provider, or pass model parameter directly to compact(). 

222 If not set, your LLMClient.invoke() will receive None and should use its own default. 

223 """ 

224 

225 mode: str = "differential" # "differential" or "full" 

226 differential_model: str | None = None # Optional: model for differential mode 

227 full_model: str | None = None # Optional: model for full mode 

228 max_messages_since_summary: int = 30 # Force full if exceeded 

229 summarization_prompt: str | None = None # None → use SUMMARIZATION_PROMPT constant 

230 differential_prompt: str | None = None # None → use DIFFERENTIAL_SUMMARIZATION_PROMPT 

231 include_tool_outputs: bool = True # Include tool outputs in summarization input 

232 tool_output_max_tokens: int = 300 # Max tokens per tool output in summarization 

233 

234 def validate(self) -> None: 

235 """Validate summarization configuration. 

236 

237 Raises: 

238 ConfigurationError: If configuration is invalid 

239 """ 

240 from harnessutils.exceptions import ConfigurationError 

241 

242 if self.mode not in ["differential", "full"]: 

243 raise ConfigurationError( 

244 f"mode must be 'differential' or 'full', got '{self.mode}'", 

245 "Set mode to either 'differential' (incremental) or 'full' " 

246 "(complete re-summarization)", 

247 ) 

248 

249 if self.max_messages_since_summary <= 0: 

250 raise ConfigurationError( 

251 f"max_messages_since_summary must be positive, " 

252 f"got {self.max_messages_since_summary}", 

253 "Set max_messages_since_summary to a positive integer (e.g., 30)", 

254 ) 

255 

256 

257@dataclass 

258class CompactionConfig: 

259 """Configuration for context compaction.""" 

260 

261 auto: bool = True # Enable auto-summarization 

262 prune: bool = True # Enable pruning 

263 

264 # Phase 2: Predictive overflow detection 

265 use_predictive: bool = True # Enable predictive overflow detection 

266 predictive_lookahead: int = 5 # Predict N turns ahead 

267 predictive_safety_margin: float = 0.8 # Trigger at 80% of limit 

268 

269 def validate(self) -> None: 

270 """Validate compaction configuration. 

271 

272 Raises: 

273 ConfigurationError: If configuration is invalid 

274 """ 

275 from harnessutils.exceptions import ConfigurationError 

276 

277 if self.predictive_lookahead <= 0: 

278 raise ConfigurationError( 

279 f"predictive_lookahead must be positive, got {self.predictive_lookahead}", 

280 "Set predictive_lookahead to a positive integer (e.g., 5 turns ahead)", 

281 ) 

282 

283 if self.predictive_safety_margin <= 0.0 or self.predictive_safety_margin >= 1.0: 

284 raise ConfigurationError( 

285 f"predictive_safety_margin must be between 0.0 and 1.0 " 

286 f"(exclusive), got {self.predictive_safety_margin}", 

287 "Set predictive_safety_margin to a value like 0.8 " 

288 "(trigger at 80% of limit)", 

289 ) 

290 

291 

292@dataclass 

293class HarnessConfig: 

294 """Main configuration for harness-utils. 

295 

296 Provides all configuration parameters for context window management 

297 with sensible defaults from the CTXWINARCH.md specification. 

298 """ 

299 

300 truncation: TruncationConfig = field(default_factory=TruncationConfig) 

301 pruning: PruningConfig = field(default_factory=PruningConfig) 

302 tokens: TokenConfig = field(default_factory=TokenConfig) 

303 model_limits: ModelLimitsConfig = field(default_factory=ModelLimitsConfig) 

304 storage: StorageConfig = field(default_factory=StorageConfig) 

305 compaction: CompactionConfig = field(default_factory=CompactionConfig) 

306 summarization: SummarizationConfig = field(default_factory=SummarizationConfig) 

307 

308 def validate(self) -> None: 

309 """Validate entire configuration. 

310 

311 Checks all sub-configs and cross-config constraints. 

312 

313 Raises: 

314 ConfigurationError: If any configuration is invalid 

315 """ 

316 from harnessutils.exceptions import ConfigurationError 

317 

318 # Validate all sub-configs 

319 self.truncation.validate() 

320 self.pruning.validate() 

321 self.model_limits.validate() 

322 self.compaction.validate() 

323 self.summarization.validate() 

324 

325 # Cross-config validations 

326 # 1. prune_protect must be less than context_limit 

327 usable_limit = ( 

328 self.model_limits.default_context_limit 

329 - self.model_limits.default_output_limit 

330 ) 

331 if self.pruning.prune_protect >= usable_limit: 

332 raise ConfigurationError( 

333 f"prune_protect ({self.pruning.prune_protect}) must be < " 

334 f"usable context ({usable_limit} = " 

335 f"{self.model_limits.default_context_limit} - " 

336 f"{self.model_limits.default_output_limit})", 

337 f"Set prune_protect to less than {usable_limit}. " 

338 f"Recommended: {int(usable_limit * 0.2)} (20% of usable context)", 

339 ) 

340 

341 @classmethod 

342 def from_dict(cls, data: dict[str, Any]) -> "HarnessConfig": 

343 """Create configuration from dictionary. 

344 

345 Args: 

346 data: Configuration dictionary 

347 

348 Returns: 

349 HarnessConfig instance 

350 """ 

351 config = cls() 

352 

353 if "truncation" in data: 

354 config.truncation = TruncationConfig(**data["truncation"]) 

355 if "pruning" in data: 

356 protected = data["pruning"].get("protected_tools") 

357 pruning_data = {k: v for k, v in data["pruning"].items() if k != "protected_tools"} 

358 if protected: 

359 pruning_data["protected_tools"] = protected 

360 config.pruning = PruningConfig(**pruning_data) 

361 if "tokens" in data: 

362 config.tokens = TokenConfig(**data["tokens"]) 

363 if "model_limits" in data: 

364 config.model_limits = ModelLimitsConfig(**data["model_limits"]) 

365 if "storage" in data: 

366 storage_data = data["storage"].copy() 

367 if "base_path" in storage_data: 

368 storage_data["base_path"] = Path(storage_data["base_path"]) 

369 config.storage = StorageConfig(**storage_data) 

370 if "compaction" in data: 

371 config.compaction = CompactionConfig(**data["compaction"]) 

372 if "summarization" in data: 

373 config.summarization = SummarizationConfig(**data["summarization"]) 

374 

375 return config 

376 

377 @classmethod 

378 def from_toml(cls, path: Path) -> "HarnessConfig": 

379 """Load configuration from TOML file. 

380 

381 Args: 

382 path: Path to TOML configuration file 

383 

384 Returns: 

385 HarnessConfig instance 

386 """ 

387 import tomllib 

388 

389 with open(path, "rb") as f: 

390 data = tomllib.load(f) 

391 

392 return cls.from_dict(data) 

393 

394 @classmethod 

395 def from_json(cls, path: Path) -> "HarnessConfig": 

396 """Load configuration from JSON file. 

397 

398 Args: 

399 path: Path to JSON configuration file 

400 

401 Returns: 

402 HarnessConfig instance 

403 """ 

404 import json 

405 

406 with open(path) as f: 

407 data = json.load(f) 

408 

409 return cls.from_dict(data)