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

165 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-04-07 09:04 -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 # Query-conditioned re-scoring 

108 query_weight: float = 1.5 # Weight for query relevance boost 

109 

110 # Deduplication (Phase 1.3) 

111 detect_duplicates: bool = True # Enable duplicate detection 

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

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

114 

115 def validate(self) -> None: 

116 """Validate pruning configuration. 

117 

118 Raises: 

119 ConfigurationError: If configuration is invalid 

120 """ 

121 from harnessutils.exceptions import ConfigurationError 

122 

123 if self.prune_minimum >= self.prune_protect: 

124 raise ConfigurationError( 

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

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

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

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

129 ) 

130 

131 if self.protect_turns < 0: 

132 raise ConfigurationError( 

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

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

135 ) 

136 

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

138 raise ConfigurationError( 

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

140 f"got {self.similarity_threshold}", 

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

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

143 ) 

144 

145 if self.duplicate_lookback <= 0: 

146 raise ConfigurationError( 

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

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

149 ) 

150 

151 # Check weights are non-negative 

152 for weight_name in [ 

153 "recency_weight", 

154 "semantic_weight", 

155 "tool_priority_weight", 

156 "error_boost", 

157 "warning_boost", 

158 "user_requested_boost", 

159 ]: 

160 weight = getattr(self, weight_name) 

161 if weight < 0: 

162 raise ConfigurationError( 

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

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

165 ) 

166 

167 

168@dataclass 

169class TokenConfig: 

170 """Configuration for token estimation.""" 

171 

172 chars_per_token: int = 4 

173 

174 

175@dataclass 

176class ModelLimitsConfig: 

177 """Configuration for model limits.""" 

178 

179 default_context_limit: int = 200_000 

180 default_output_limit: int = 8_192 

181 

182 def validate(self) -> None: 

183 """Validate model limits configuration. 

184 

185 Raises: 

186 ConfigurationError: If configuration is invalid 

187 """ 

188 from harnessutils.exceptions import ConfigurationError 

189 

190 if self.default_context_limit <= 0: 

191 raise ConfigurationError( 

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

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

194 ) 

195 

196 if self.default_output_limit <= 0: 

197 raise ConfigurationError( 

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

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

200 ) 

201 

202 if self.default_output_limit >= self.default_context_limit: 

203 raise ConfigurationError( 

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

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

206 "Set default_output_limit to less than default_context_limit. " 

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

208 ) 

209 

210 

211@dataclass 

212class StorageConfig: 

213 """Configuration for storage layer.""" 

214 

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

216 retention_days: int = 7 # For truncated outputs 

217 

218 

219@dataclass 

220class SummarizationConfig: 

221 """Configuration for Tier 3: Summarization. 

222 

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

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

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

226 """ 

227 

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

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

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

231 max_messages_since_summary: int = 30 # Force full if exceeded 

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

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

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

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

236 

237 def validate(self) -> None: 

238 """Validate summarization configuration. 

239 

240 Raises: 

241 ConfigurationError: If configuration is invalid 

242 """ 

243 from harnessutils.exceptions import ConfigurationError 

244 

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

246 raise ConfigurationError( 

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

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

249 "(complete re-summarization)", 

250 ) 

251 

252 if self.max_messages_since_summary <= 0: 

253 raise ConfigurationError( 

254 f"max_messages_since_summary must be positive, " 

255 f"got {self.max_messages_since_summary}", 

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

257 ) 

258 

259 

260@dataclass 

261class CompactionConfig: 

262 """Configuration for context compaction.""" 

263 

264 auto: bool = True # Enable auto-summarization 

265 prune: bool = True # Enable pruning 

266 

267 # Phase 2: Predictive overflow detection 

268 use_predictive: bool = True # Enable predictive overflow detection 

269 predictive_lookahead: int = 5 # Predict N turns ahead 

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

271 

272 def validate(self) -> None: 

273 """Validate compaction configuration. 

274 

275 Raises: 

276 ConfigurationError: If configuration is invalid 

277 """ 

278 from harnessutils.exceptions import ConfigurationError 

279 

280 if self.predictive_lookahead <= 0: 

281 raise ConfigurationError( 

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

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

284 ) 

285 

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

287 raise ConfigurationError( 

288 f"predictive_safety_margin must be between 0.0 and 1.0 " 

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

290 "Set predictive_safety_margin to a value like 0.8 " 

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

292 ) 

293 

294 

295@dataclass 

296class HarnessConfig: 

297 """Main configuration for harness-utils. 

298 

299 Provides all configuration parameters for context window management 

300 with sensible defaults from the CTXWINARCH.md specification. 

301 """ 

302 

303 truncation: TruncationConfig = field(default_factory=TruncationConfig) 

304 pruning: PruningConfig = field(default_factory=PruningConfig) 

305 tokens: TokenConfig = field(default_factory=TokenConfig) 

306 model_limits: ModelLimitsConfig = field(default_factory=ModelLimitsConfig) 

307 storage: StorageConfig = field(default_factory=StorageConfig) 

308 compaction: CompactionConfig = field(default_factory=CompactionConfig) 

309 summarization: SummarizationConfig = field(default_factory=SummarizationConfig) 

310 

311 def validate(self) -> None: 

312 """Validate entire configuration. 

313 

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

315 

316 Raises: 

317 ConfigurationError: If any configuration is invalid 

318 """ 

319 from harnessutils.exceptions import ConfigurationError 

320 

321 # Validate all sub-configs 

322 self.truncation.validate() 

323 self.pruning.validate() 

324 self.model_limits.validate() 

325 self.compaction.validate() 

326 self.summarization.validate() 

327 

328 # Cross-config validations 

329 # 1. prune_protect must be less than context_limit 

330 usable_limit = ( 

331 self.model_limits.default_context_limit 

332 - self.model_limits.default_output_limit 

333 ) 

334 if self.pruning.prune_protect >= usable_limit: 

335 raise ConfigurationError( 

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

337 f"usable context ({usable_limit} = " 

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

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

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

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

342 ) 

343 

344 @classmethod 

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

346 """Create configuration from dictionary. 

347 

348 Args: 

349 data: Configuration dictionary 

350 

351 Returns: 

352 HarnessConfig instance 

353 """ 

354 config = cls() 

355 

356 if "truncation" in data: 

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

358 if "pruning" in data: 

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

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

361 if protected: 

362 pruning_data["protected_tools"] = protected 

363 config.pruning = PruningConfig(**pruning_data) 

364 if "tokens" in data: 

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

366 if "model_limits" in data: 

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

368 if "storage" in data: 

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

370 if "base_path" in storage_data: 

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

372 config.storage = StorageConfig(**storage_data) 

373 if "compaction" in data: 

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

375 if "summarization" in data: 

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

377 

378 return config 

379 

380 @classmethod 

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

382 """Load configuration from TOML file. 

383 

384 Args: 

385 path: Path to TOML configuration file 

386 

387 Returns: 

388 HarnessConfig instance 

389 """ 

390 import tomllib 

391 

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

393 data = tomllib.load(f) 

394 

395 return cls.from_dict(data) 

396 

397 @classmethod 

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

399 """Load configuration from JSON file. 

400 

401 Args: 

402 path: Path to JSON configuration file 

403 

404 Returns: 

405 HarnessConfig instance 

406 """ 

407 import json 

408 

409 with open(path) as f: 

410 data = json.load(f) 

411 

412 return cls.from_dict(data)