massgen 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of massgen might be problematic. Click here for more details.

Files changed (84) hide show
  1. massgen/__init__.py +1 -1
  2. massgen/backend/base_with_custom_tool_and_mcp.py +453 -23
  3. massgen/backend/capabilities.py +39 -0
  4. massgen/backend/chat_completions.py +111 -197
  5. massgen/backend/claude.py +210 -181
  6. massgen/backend/gemini.py +1015 -1559
  7. massgen/backend/grok.py +3 -2
  8. massgen/backend/response.py +160 -220
  9. massgen/chat_agent.py +340 -20
  10. massgen/cli.py +399 -25
  11. massgen/config_builder.py +20 -54
  12. massgen/config_validator.py +931 -0
  13. massgen/configs/README.md +95 -10
  14. massgen/configs/memory/gpt5mini_gemini_baseline_research_to_implementation.yaml +94 -0
  15. massgen/configs/memory/gpt5mini_gemini_context_window_management.yaml +187 -0
  16. massgen/configs/memory/gpt5mini_gemini_research_to_implementation.yaml +127 -0
  17. massgen/configs/memory/gpt5mini_high_reasoning_gemini.yaml +107 -0
  18. massgen/configs/memory/single_agent_compression_test.yaml +64 -0
  19. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_mcp_example.yaml +1 -0
  20. massgen/configs/tools/custom_tools/claude_custom_tool_example_no_path.yaml +1 -1
  21. massgen/configs/tools/custom_tools/claude_custom_tool_with_mcp_example.yaml +1 -0
  22. massgen/configs/tools/custom_tools/computer_use_browser_example.yaml +1 -1
  23. massgen/configs/tools/custom_tools/computer_use_docker_example.yaml +1 -1
  24. massgen/configs/tools/custom_tools/gemini_custom_tool_with_mcp_example.yaml +1 -0
  25. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_mcp_example.yaml +1 -0
  26. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_mcp_example.yaml +1 -0
  27. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_mcp_example.yaml +1 -0
  28. massgen/configs/tools/custom_tools/interop/ag2_and_langgraph_lesson_planner.yaml +65 -0
  29. massgen/configs/tools/custom_tools/interop/ag2_and_openai_assistant_lesson_planner.yaml +65 -0
  30. massgen/configs/tools/custom_tools/interop/ag2_lesson_planner_example.yaml +48 -0
  31. massgen/configs/tools/custom_tools/interop/agentscope_lesson_planner_example.yaml +48 -0
  32. massgen/configs/tools/custom_tools/interop/langgraph_lesson_planner_example.yaml +49 -0
  33. massgen/configs/tools/custom_tools/interop/openai_assistant_lesson_planner_example.yaml +50 -0
  34. massgen/configs/tools/custom_tools/interop/smolagent_lesson_planner_example.yaml +49 -0
  35. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_mcp_example.yaml +1 -0
  36. massgen/configs/tools/custom_tools/two_models_with_tools_example.yaml +44 -0
  37. massgen/formatter/_gemini_formatter.py +61 -15
  38. massgen/memory/README.md +277 -0
  39. massgen/memory/__init__.py +26 -0
  40. massgen/memory/_base.py +193 -0
  41. massgen/memory/_compression.py +237 -0
  42. massgen/memory/_context_monitor.py +211 -0
  43. massgen/memory/_conversation.py +255 -0
  44. massgen/memory/_fact_extraction_prompts.py +333 -0
  45. massgen/memory/_mem0_adapters.py +257 -0
  46. massgen/memory/_persistent.py +687 -0
  47. massgen/memory/docker-compose.qdrant.yml +36 -0
  48. massgen/memory/docs/DESIGN.md +388 -0
  49. massgen/memory/docs/QUICKSTART.md +409 -0
  50. massgen/memory/docs/SUMMARY.md +319 -0
  51. massgen/memory/docs/agent_use_memory.md +408 -0
  52. massgen/memory/docs/orchestrator_use_memory.md +586 -0
  53. massgen/memory/examples.py +237 -0
  54. massgen/orchestrator.py +207 -7
  55. massgen/tests/memory/test_agent_compression.py +174 -0
  56. massgen/tests/memory/test_context_window_management.py +286 -0
  57. massgen/tests/memory/test_force_compression.py +154 -0
  58. massgen/tests/memory/test_simple_compression.py +147 -0
  59. massgen/tests/test_ag2_lesson_planner.py +223 -0
  60. massgen/tests/test_agent_memory.py +534 -0
  61. massgen/tests/test_config_validator.py +1156 -0
  62. massgen/tests/test_conversation_memory.py +382 -0
  63. massgen/tests/test_langgraph_lesson_planner.py +223 -0
  64. massgen/tests/test_orchestrator_memory.py +620 -0
  65. massgen/tests/test_persistent_memory.py +435 -0
  66. massgen/token_manager/token_manager.py +6 -0
  67. massgen/tool/__init__.py +2 -9
  68. massgen/tool/_decorators.py +52 -0
  69. massgen/tool/_extraframework_agents/ag2_lesson_planner_tool.py +251 -0
  70. massgen/tool/_extraframework_agents/agentscope_lesson_planner_tool.py +303 -0
  71. massgen/tool/_extraframework_agents/langgraph_lesson_planner_tool.py +275 -0
  72. massgen/tool/_extraframework_agents/openai_assistant_lesson_planner_tool.py +247 -0
  73. massgen/tool/_extraframework_agents/smolagent_lesson_planner_tool.py +180 -0
  74. massgen/tool/_manager.py +102 -16
  75. massgen/tool/_registered_tool.py +3 -0
  76. massgen/tool/_result.py +3 -0
  77. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/METADATA +138 -77
  78. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/RECORD +82 -37
  79. massgen/backend/gemini_mcp_manager.py +0 -545
  80. massgen/backend/gemini_trackers.py +0 -344
  81. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/WHEEL +0 -0
  82. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/entry_points.txt +0 -0
  83. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/licenses/LICENSE +0 -0
  84. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,931 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Configuration validation for MassGen YAML/JSON configs.
4
+
5
+ This module provides comprehensive validation for MassGen configuration files,
6
+ checking schema structure, required fields, valid values, and best practices.
7
+
8
+ Usage:
9
+ from massgen.config_validator import ConfigValidator
10
+
11
+ # Validate a config file
12
+ validator = ConfigValidator()
13
+ result = validator.validate_config_file("config.yaml")
14
+
15
+ if result.has_errors():
16
+ print(result.format_errors())
17
+ sys.exit(1)
18
+
19
+ if result.has_warnings():
20
+ print(result.format_warnings())
21
+
22
+ # Validate a config dict
23
+ result = validator.validate_config(config_dict)
24
+ """
25
+
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ import yaml
31
+
32
+ from .backend.capabilities import (
33
+ BACKEND_CAPABILITIES,
34
+ get_capabilities,
35
+ validate_backend_config,
36
+ )
37
+ from .mcp_tools.config_validator import MCPConfigValidator
38
+
39
+
40
+ @dataclass
41
+ class ValidationIssue:
42
+ """Represents a validation error or warning."""
43
+
44
+ message: str
45
+ location: str
46
+ suggestion: Optional[str] = None
47
+ severity: str = "error" # "error" or "warning"
48
+
49
+ def __str__(self) -> str:
50
+ """Format issue for display."""
51
+ severity_symbol = "❌" if self.severity == "error" else "⚠️"
52
+ parts = [f"{severity_symbol} [{self.location}] {self.message}"]
53
+ if self.suggestion:
54
+ parts.append(f" 💡 Suggestion: {self.suggestion}")
55
+ return "\n".join(parts)
56
+
57
+
58
+ @dataclass
59
+ class ValidationResult:
60
+ """Aggregates all validation errors and warnings."""
61
+
62
+ errors: List[ValidationIssue] = field(default_factory=list)
63
+ warnings: List[ValidationIssue] = field(default_factory=list)
64
+
65
+ def add_error(self, message: str, location: str, suggestion: Optional[str] = None) -> None:
66
+ """Add a validation error."""
67
+ self.errors.append(ValidationIssue(message, location, suggestion, "error"))
68
+
69
+ def add_warning(self, message: str, location: str, suggestion: Optional[str] = None) -> None:
70
+ """Add a validation warning."""
71
+ self.warnings.append(ValidationIssue(message, location, suggestion, "warning"))
72
+
73
+ def has_errors(self) -> bool:
74
+ """Check if there are any errors."""
75
+ return len(self.errors) > 0
76
+
77
+ def has_warnings(self) -> bool:
78
+ """Check if there are any warnings."""
79
+ return len(self.warnings) > 0
80
+
81
+ def is_valid(self) -> bool:
82
+ """Check if config is valid (no errors)."""
83
+ return not self.has_errors()
84
+
85
+ def format_errors(self) -> str:
86
+ """Format all errors for display."""
87
+ if not self.errors:
88
+ return ""
89
+ lines = ["\n🔴 Configuration Errors Found:\n"]
90
+ lines.extend(str(error) for error in self.errors)
91
+ return "\n".join(lines)
92
+
93
+ def format_warnings(self) -> str:
94
+ """Format all warnings for display."""
95
+ if not self.warnings:
96
+ return ""
97
+ lines = ["\n🟡 Configuration Warnings:\n"]
98
+ lines.extend(str(warning) for warning in self.warnings)
99
+ return "\n".join(lines)
100
+
101
+ def format_all(self) -> str:
102
+ """Format all issues for display."""
103
+ parts = []
104
+ if self.has_errors():
105
+ parts.append(self.format_errors())
106
+ if self.has_warnings():
107
+ parts.append(self.format_warnings())
108
+ return "\n".join(parts) if parts else "✅ Configuration is valid!"
109
+
110
+ def to_dict(self) -> Dict[str, Any]:
111
+ """Convert to dictionary for JSON output."""
112
+ return {
113
+ "valid": self.is_valid(),
114
+ "error_count": len(self.errors),
115
+ "warning_count": len(self.warnings),
116
+ "errors": [{"message": e.message, "location": e.location, "suggestion": e.suggestion} for e in self.errors],
117
+ "warnings": [{"message": w.message, "location": w.location, "suggestion": w.suggestion} for w in self.warnings],
118
+ }
119
+
120
+
121
+ class ConfigValidator:
122
+ """Validates MassGen configuration files."""
123
+
124
+ # V1 config keywords that are no longer supported
125
+ V1_KEYWORDS = {
126
+ "models",
127
+ "model_configs",
128
+ "num_agents",
129
+ "max_rounds",
130
+ "consensus_threshold",
131
+ "voting_enabled",
132
+ "enable_voting",
133
+ }
134
+
135
+ # Valid permission modes for backends that support them
136
+ VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan"}
137
+
138
+ # Valid display types for UI
139
+ VALID_DISPLAY_TYPES = {"rich_terminal", "simple"}
140
+
141
+ # Valid voting sensitivity levels
142
+ VALID_VOTING_SENSITIVITY = {"lenient", "balanced", "strict"}
143
+
144
+ # Valid answer novelty requirements
145
+ VALID_ANSWER_NOVELTY = {"lenient", "balanced", "strict"}
146
+
147
+ def __init__(self):
148
+ """Initialize the validator."""
149
+
150
+ def validate_config_file(self, config_path: str) -> ValidationResult:
151
+ """
152
+ Validate a configuration file.
153
+
154
+ Args:
155
+ config_path: Path to YAML or JSON config file
156
+
157
+ Returns:
158
+ ValidationResult with any errors or warnings found
159
+ """
160
+ result = ValidationResult()
161
+
162
+ # Check file exists
163
+ path = Path(config_path)
164
+ if not path.exists():
165
+ result.add_error(f"Config file not found: {config_path}", "file", "Check the file path")
166
+ return result
167
+
168
+ # Load config file
169
+ try:
170
+ with open(path, "r") as f:
171
+ if path.suffix in [".yaml", ".yml"]:
172
+ config = yaml.safe_load(f)
173
+ elif path.suffix == ".json":
174
+ import json
175
+
176
+ config = json.load(f)
177
+ else:
178
+ result.add_error(
179
+ f"Unsupported file format: {path.suffix}",
180
+ "file",
181
+ "Use .yaml, .yml, or .json extension",
182
+ )
183
+ return result
184
+ except Exception as e:
185
+ result.add_error(f"Failed to parse config file: {e}", "file", "Check file syntax")
186
+ return result
187
+
188
+ # Validate the loaded config
189
+ return self.validate_config(config)
190
+
191
+ def validate_config(self, config: Dict[str, Any]) -> ValidationResult:
192
+ """
193
+ Validate a configuration dictionary.
194
+
195
+ Args:
196
+ config: Configuration dictionary
197
+
198
+ Returns:
199
+ ValidationResult with any errors or warnings found
200
+ """
201
+ result = ValidationResult()
202
+
203
+ if not isinstance(config, dict):
204
+ result.add_error("Config must be a dictionary/object", "root", "Check YAML/JSON syntax")
205
+ return result
206
+
207
+ # Check for V1 config keywords (instant fail)
208
+ self._check_v1_keywords(config, result)
209
+ if result.has_errors():
210
+ return result # Stop validation if V1 detected
211
+
212
+ # Validate top-level structure
213
+ self._validate_top_level(config, result)
214
+
215
+ # Validate agents (if present)
216
+ if "agents" in config or "agent" in config:
217
+ self._validate_agents(config, result)
218
+
219
+ # Validate orchestrator (if present)
220
+ if "orchestrator" in config:
221
+ self._validate_orchestrator(config["orchestrator"], result)
222
+
223
+ # Validate UI (if present)
224
+ if "ui" in config:
225
+ self._validate_ui(config["ui"], result)
226
+
227
+ # Validate memory (if present)
228
+ if "memory" in config:
229
+ self._validate_memory(config["memory"], result)
230
+
231
+ # Check for warnings (best practices, deprecations, etc.)
232
+ self._check_warnings(config, result)
233
+
234
+ return result
235
+
236
+ def _check_v1_keywords(self, config: Dict[str, Any], result: ValidationResult) -> None:
237
+ """Check for V1 config keywords and reject them."""
238
+ found_v1_keywords = []
239
+ for keyword in self.V1_KEYWORDS:
240
+ if keyword in config:
241
+ found_v1_keywords.append(keyword)
242
+
243
+ if found_v1_keywords:
244
+ result.add_error(
245
+ f"V1 config format detected (found: {', '.join(found_v1_keywords)}). " "V1 configs are no longer supported.",
246
+ "root",
247
+ "Migrate to V2 config format. See docs/source/reference/yaml_schema.rst for the current schema.",
248
+ )
249
+
250
+ def _validate_top_level(self, config: Dict[str, Any], result: ValidationResult) -> None:
251
+ """Validate top-level config structure (Level 1)."""
252
+ # Require either 'agents' (list) or 'agent' (single)
253
+ has_agents = "agents" in config
254
+ has_agent = "agent" in config
255
+
256
+ if not has_agents and not has_agent:
257
+ result.add_error(
258
+ "Config must have either 'agents' (list) or 'agent' (single agent)",
259
+ "root",
260
+ "Add 'agents: [...]' for multiple agents or 'agent: {...}' for a single agent",
261
+ )
262
+ return
263
+
264
+ if has_agents and has_agent:
265
+ result.add_error(
266
+ "Config cannot have both 'agents' and 'agent' fields",
267
+ "root",
268
+ "Use either 'agents' for multiple agents or 'agent' for a single agent",
269
+ )
270
+ return
271
+
272
+ # Validate agents is a list (if present)
273
+ if has_agents and not isinstance(config["agents"], list):
274
+ result.add_error(
275
+ f"'agents' must be a list, got {type(config['agents']).__name__}",
276
+ "root.agents",
277
+ "Use 'agents: [...]' for multiple agents",
278
+ )
279
+
280
+ # Validate agent is a dict (if present)
281
+ if has_agent and not isinstance(config["agent"], dict):
282
+ result.add_error(
283
+ f"'agent' must be a dictionary, got {type(config['agent']).__name__}",
284
+ "root.agent",
285
+ "Use 'agent: {...}' for a single agent",
286
+ )
287
+
288
+ def _validate_agents(self, config: Dict[str, Any], result: ValidationResult) -> None:
289
+ """Validate agent configurations (Level 2)."""
290
+ # Get agents list (normalize single agent to list)
291
+ if "agents" in config:
292
+ agents = config["agents"]
293
+ if not isinstance(agents, list):
294
+ return # Already reported error in _validate_top_level
295
+ else:
296
+ agents = [config["agent"]]
297
+
298
+ # Track agent IDs for duplicate detection
299
+ agent_ids: List[str] = []
300
+
301
+ for i, agent_config in enumerate(agents):
302
+ agent_location = f"agents[{i}]" if "agents" in config else "agent"
303
+
304
+ # Validate agent is a dict
305
+ if not isinstance(agent_config, dict):
306
+ result.add_error(
307
+ f"Agent must be a dictionary, got {type(agent_config).__name__}",
308
+ agent_location,
309
+ "Use 'id', 'backend', and optional 'system_message' fields",
310
+ )
311
+ continue
312
+
313
+ # Validate required field: id
314
+ if "id" not in agent_config:
315
+ result.add_error("Agent missing required field 'id'", agent_location, "Add 'id: \"agent-name\"'")
316
+ else:
317
+ agent_id = agent_config["id"]
318
+ if not isinstance(agent_id, str):
319
+ result.add_error(
320
+ f"Agent 'id' must be a string, got {type(agent_id).__name__}",
321
+ f"{agent_location}.id",
322
+ "Use a string identifier like 'id: \"researcher\"'",
323
+ )
324
+ elif agent_id in agent_ids:
325
+ result.add_error(
326
+ f"Duplicate agent ID: '{agent_id}'",
327
+ f"{agent_location}.id",
328
+ "Each agent must have a unique ID",
329
+ )
330
+ else:
331
+ agent_ids.append(agent_id)
332
+
333
+ # Validate required field: backend
334
+ if "backend" not in agent_config:
335
+ result.add_error(
336
+ "Agent missing required field 'backend'",
337
+ agent_location,
338
+ "Add 'backend: {type: ..., model: ...}'",
339
+ )
340
+ else:
341
+ self._validate_backend(agent_config["backend"], f"{agent_location}.backend", result)
342
+
343
+ # Validate optional field: system_message
344
+ if "system_message" in agent_config:
345
+ system_message = agent_config["system_message"]
346
+ if not isinstance(system_message, str):
347
+ result.add_error(
348
+ f"Agent 'system_message' must be a string, got {type(system_message).__name__}",
349
+ f"{agent_location}.system_message",
350
+ "Use a string for the system message",
351
+ )
352
+
353
+ def _validate_backend(self, backend_config: Dict[str, Any], location: str, result: ValidationResult) -> None:
354
+ """Validate backend configuration (Level 3)."""
355
+ if not isinstance(backend_config, dict):
356
+ result.add_error(
357
+ f"Backend must be a dictionary, got {type(backend_config).__name__}",
358
+ location,
359
+ "Use 'type', 'model', and other backend-specific fields",
360
+ )
361
+ return
362
+
363
+ # Validate required field: type
364
+ if "type" not in backend_config:
365
+ result.add_error("Backend missing required field 'type'", location, "Add 'type: \"openai\"' or similar")
366
+ return
367
+
368
+ backend_type = backend_config["type"]
369
+ if not isinstance(backend_type, str):
370
+ result.add_error(
371
+ f"Backend 'type' must be a string, got {type(backend_type).__name__}",
372
+ f"{location}.type",
373
+ "Use a string like 'openai', 'claude', 'gemini', etc.",
374
+ )
375
+ return
376
+
377
+ # Validate backend type is supported
378
+ if backend_type not in BACKEND_CAPABILITIES:
379
+ valid_types = ", ".join(sorted(BACKEND_CAPABILITIES.keys()))
380
+ result.add_error(
381
+ f"Unknown backend type: '{backend_type}'",
382
+ f"{location}.type",
383
+ f"Use one of: {valid_types}",
384
+ )
385
+ return
386
+
387
+ # Validate model field
388
+ # Model is optional for:
389
+ # - ag2 (uses agent_config.llm_config instead)
390
+ # - claude_code (has default model)
391
+ # - backends with default models in BACKEND_CAPABILITIES
392
+ caps = get_capabilities(backend_type)
393
+ has_default_model = caps and caps.default_model != "custom"
394
+
395
+ if backend_type != "ag2" and not has_default_model:
396
+ if "model" not in backend_config:
397
+ result.add_error("Backend missing required field 'model'", location, "Add 'model: \"model-name\"'")
398
+ else:
399
+ model = backend_config["model"]
400
+ if not isinstance(model, str):
401
+ result.add_error(
402
+ f"Backend 'model' must be a string, got {type(model).__name__}",
403
+ f"{location}.model",
404
+ "Use a string model identifier",
405
+ )
406
+ elif "model" in backend_config:
407
+ # Validate type if model is provided (even if optional)
408
+ model = backend_config["model"]
409
+ if not isinstance(model, str):
410
+ result.add_error(
411
+ f"Backend 'model' must be a string, got {type(model).__name__}",
412
+ f"{location}.model",
413
+ "Use a string model identifier",
414
+ )
415
+
416
+ # Validate backend-specific capabilities using existing validator
417
+ capability_errors = validate_backend_config(backend_type, backend_config)
418
+ for error_msg in capability_errors:
419
+ result.add_error(error_msg, location, "Check backend capabilities in documentation")
420
+
421
+ # Validate permission_mode if present
422
+ if "permission_mode" in backend_config:
423
+ permission_mode = backend_config["permission_mode"]
424
+ if permission_mode not in self.VALID_PERMISSION_MODES:
425
+ valid_modes = ", ".join(sorted(self.VALID_PERMISSION_MODES))
426
+ result.add_error(
427
+ f"Invalid permission_mode: '{permission_mode}'",
428
+ f"{location}.permission_mode",
429
+ f"Use one of: {valid_modes}",
430
+ )
431
+
432
+ # Validate tool filtering (allowed_tools, exclude_tools, disallowed_tools)
433
+ self._validate_tool_filtering(backend_config, location, result)
434
+
435
+ # Validate MCP servers if present
436
+ if "mcp_servers" in backend_config:
437
+ try:
438
+ MCPConfigValidator.validate_backend_mcp_config(backend_config)
439
+ except Exception as e:
440
+ result.add_error(
441
+ f"MCP configuration error: {str(e)}",
442
+ f"{location}.mcp_servers",
443
+ "Check MCP server configuration syntax",
444
+ )
445
+
446
+ # Validate boolean fields
447
+ boolean_fields = [
448
+ "enable_web_search",
449
+ "enable_code_execution",
450
+ "enable_code_interpreter",
451
+ ]
452
+ for field_name in boolean_fields:
453
+ if field_name in backend_config:
454
+ value = backend_config[field_name]
455
+ if not isinstance(value, bool):
456
+ result.add_error(
457
+ f"Backend '{field_name}' must be a boolean, got {type(value).__name__}",
458
+ f"{location}.{field_name}",
459
+ "Use 'true' or 'false'",
460
+ )
461
+
462
+ def _validate_tool_filtering(
463
+ self,
464
+ backend_config: Dict[str, Any],
465
+ location: str,
466
+ result: ValidationResult,
467
+ ) -> None:
468
+ """Validate tool filtering parameters."""
469
+ # Check allowed_tools
470
+ if "allowed_tools" in backend_config:
471
+ allowed_tools = backend_config["allowed_tools"]
472
+ if not isinstance(allowed_tools, list):
473
+ result.add_error(
474
+ f"'allowed_tools' must be a list, got {type(allowed_tools).__name__}",
475
+ f"{location}.allowed_tools",
476
+ "Use a list of tool names",
477
+ )
478
+ else:
479
+ for i, tool in enumerate(allowed_tools):
480
+ if not isinstance(tool, str):
481
+ result.add_error(
482
+ f"'allowed_tools[{i}]' must be a string, got {type(tool).__name__}",
483
+ f"{location}.allowed_tools[{i}]",
484
+ "Use string tool names",
485
+ )
486
+
487
+ # Check exclude_tools
488
+ if "exclude_tools" in backend_config:
489
+ exclude_tools = backend_config["exclude_tools"]
490
+ if not isinstance(exclude_tools, list):
491
+ result.add_error(
492
+ f"'exclude_tools' must be a list, got {type(exclude_tools).__name__}",
493
+ f"{location}.exclude_tools",
494
+ "Use a list of tool names",
495
+ )
496
+ else:
497
+ for i, tool in enumerate(exclude_tools):
498
+ if not isinstance(tool, str):
499
+ result.add_error(
500
+ f"'exclude_tools[{i}]' must be a string, got {type(tool).__name__}",
501
+ f"{location}.exclude_tools[{i}]",
502
+ "Use string tool names",
503
+ )
504
+
505
+ # Check disallowed_tools (claude_code specific)
506
+ if "disallowed_tools" in backend_config:
507
+ disallowed_tools = backend_config["disallowed_tools"]
508
+ if not isinstance(disallowed_tools, list):
509
+ result.add_error(
510
+ f"'disallowed_tools' must be a list, got {type(disallowed_tools).__name__}",
511
+ f"{location}.disallowed_tools",
512
+ "Use a list of tool patterns",
513
+ )
514
+ else:
515
+ for i, tool in enumerate(disallowed_tools):
516
+ if not isinstance(tool, str):
517
+ result.add_error(
518
+ f"'disallowed_tools[{i}]' must be a string, got {type(tool).__name__}",
519
+ f"{location}.disallowed_tools[{i}]",
520
+ "Use string tool patterns",
521
+ )
522
+
523
+ def _validate_orchestrator(self, orchestrator_config: Dict[str, Any], result: ValidationResult) -> None:
524
+ """Validate orchestrator configuration (Level 5)."""
525
+ location = "orchestrator"
526
+
527
+ if not isinstance(orchestrator_config, dict):
528
+ result.add_error(
529
+ f"Orchestrator must be a dictionary, got {type(orchestrator_config).__name__}",
530
+ location,
531
+ "Use orchestrator fields like snapshot_storage, context_paths, etc.",
532
+ )
533
+ return
534
+
535
+ # Validate context_paths if present
536
+ if "context_paths" in orchestrator_config:
537
+ context_paths = orchestrator_config["context_paths"]
538
+ if not isinstance(context_paths, list):
539
+ result.add_error(
540
+ f"'context_paths' must be a list, got {type(context_paths).__name__}",
541
+ f"{location}.context_paths",
542
+ "Use a list of path configurations",
543
+ )
544
+ else:
545
+ for i, path_config in enumerate(context_paths):
546
+ if not isinstance(path_config, dict):
547
+ result.add_error(
548
+ f"'context_paths[{i}]' must be a dictionary",
549
+ f"{location}.context_paths[{i}]",
550
+ "Use 'path' and 'permission' fields",
551
+ )
552
+ continue
553
+
554
+ # Check required field: path
555
+ if "path" not in path_config:
556
+ result.add_error(
557
+ "context_paths entry missing 'path' field",
558
+ f"{location}.context_paths[{i}]",
559
+ "Add 'path: \"/path/to/dir\"'",
560
+ )
561
+
562
+ # Check permission field
563
+ if "permission" in path_config:
564
+ permission = path_config["permission"]
565
+ if permission not in ["read", "write"]:
566
+ result.add_error(
567
+ f"Invalid permission: '{permission}'",
568
+ f"{location}.context_paths[{i}].permission",
569
+ "Use 'read' or 'write'",
570
+ )
571
+
572
+ # Validate coordination if present
573
+ if "coordination" in orchestrator_config:
574
+ coordination = orchestrator_config["coordination"]
575
+ if not isinstance(coordination, dict):
576
+ result.add_error(
577
+ f"'coordination' must be a dictionary, got {type(coordination).__name__}",
578
+ f"{location}.coordination",
579
+ "Use coordination fields like enable_planning_mode, max_orchestration_restarts, etc.",
580
+ )
581
+ else:
582
+ # Validate boolean fields
583
+ boolean_fields = ["enable_planning_mode"]
584
+ for field_name in boolean_fields:
585
+ if field_name in coordination:
586
+ value = coordination[field_name]
587
+ if not isinstance(value, bool):
588
+ result.add_error(
589
+ f"'{field_name}' must be a boolean, got {type(value).__name__}",
590
+ f"{location}.coordination.{field_name}",
591
+ "Use 'true' or 'false'",
592
+ )
593
+
594
+ # Validate integer fields
595
+ if "max_orchestration_restarts" in coordination:
596
+ value = coordination["max_orchestration_restarts"]
597
+ if not isinstance(value, int) or value < 0:
598
+ result.add_error(
599
+ "'max_orchestration_restarts' must be a non-negative integer",
600
+ f"{location}.coordination.max_orchestration_restarts",
601
+ "Use a value like 0, 1, 2, etc.",
602
+ )
603
+
604
+ # Validate voting_sensitivity if present
605
+ if "voting_sensitivity" in orchestrator_config:
606
+ voting_sensitivity = orchestrator_config["voting_sensitivity"]
607
+ if voting_sensitivity not in self.VALID_VOTING_SENSITIVITY:
608
+ valid_values = ", ".join(sorted(self.VALID_VOTING_SENSITIVITY))
609
+ result.add_error(
610
+ f"Invalid voting_sensitivity: '{voting_sensitivity}'",
611
+ f"{location}.voting_sensitivity",
612
+ f"Use one of: {valid_values}",
613
+ )
614
+
615
+ # Validate answer_novelty_requirement if present
616
+ if "answer_novelty_requirement" in orchestrator_config:
617
+ answer_novelty = orchestrator_config["answer_novelty_requirement"]
618
+ if answer_novelty not in self.VALID_ANSWER_NOVELTY:
619
+ valid_values = ", ".join(sorted(self.VALID_ANSWER_NOVELTY))
620
+ result.add_error(
621
+ f"Invalid answer_novelty_requirement: '{answer_novelty}'",
622
+ f"{location}.answer_novelty_requirement",
623
+ f"Use one of: {valid_values}",
624
+ )
625
+
626
+ # Validate timeout if present
627
+ if "timeout" in orchestrator_config:
628
+ timeout = orchestrator_config["timeout"]
629
+ if not isinstance(timeout, dict):
630
+ result.add_error(
631
+ f"'timeout' must be a dictionary, got {type(timeout).__name__}",
632
+ f"{location}.timeout",
633
+ "Use 'orchestrator_timeout_seconds: <number>'",
634
+ )
635
+ elif "orchestrator_timeout_seconds" in timeout:
636
+ value = timeout["orchestrator_timeout_seconds"]
637
+ if not isinstance(value, (int, float)) or value <= 0:
638
+ result.add_error(
639
+ "'orchestrator_timeout_seconds' must be a positive number",
640
+ f"{location}.timeout.orchestrator_timeout_seconds",
641
+ "Use a value like 1800 (30 minutes)",
642
+ )
643
+
644
+ # Validate boolean fields
645
+ boolean_fields = ["skip_coordination_rounds", "debug_final_answer"]
646
+ for field_name in boolean_fields:
647
+ if field_name in orchestrator_config:
648
+ value = orchestrator_config[field_name]
649
+ # debug_final_answer can be a string or boolean
650
+ if field_name == "debug_final_answer":
651
+ if not isinstance(value, (bool, str)):
652
+ result.add_error(
653
+ f"'{field_name}' must be a boolean or string, got {type(value).__name__}",
654
+ f"{location}.{field_name}",
655
+ "Use 'true', 'false', or a string value",
656
+ )
657
+ else:
658
+ if not isinstance(value, bool):
659
+ result.add_error(
660
+ f"'{field_name}' must be a boolean, got {type(value).__name__}",
661
+ f"{location}.{field_name}",
662
+ "Use 'true' or 'false'",
663
+ )
664
+
665
+ def _validate_ui(self, ui_config: Dict[str, Any], result: ValidationResult) -> None:
666
+ """Validate UI configuration (Level 6)."""
667
+ location = "ui"
668
+
669
+ if not isinstance(ui_config, dict):
670
+ result.add_error(
671
+ f"UI must be a dictionary, got {type(ui_config).__name__}",
672
+ location,
673
+ "Use UI fields like display_type and logging_enabled",
674
+ )
675
+ return
676
+
677
+ # Validate display_type if present
678
+ if "display_type" in ui_config:
679
+ display_type = ui_config["display_type"]
680
+ if display_type not in self.VALID_DISPLAY_TYPES:
681
+ valid_types = ", ".join(sorted(self.VALID_DISPLAY_TYPES))
682
+ result.add_error(
683
+ f"Invalid display_type: '{display_type}'",
684
+ f"{location}.display_type",
685
+ f"Use one of: {valid_types}",
686
+ )
687
+
688
+ # Validate logging_enabled if present
689
+ if "logging_enabled" in ui_config:
690
+ logging_enabled = ui_config["logging_enabled"]
691
+ if not isinstance(logging_enabled, bool):
692
+ result.add_error(
693
+ f"'logging_enabled' must be a boolean, got {type(logging_enabled).__name__}",
694
+ f"{location}.logging_enabled",
695
+ "Use 'true' or 'false'",
696
+ )
697
+
698
+ def _validate_memory(self, memory_config: Dict[str, Any], result: ValidationResult) -> None:
699
+ """Validate memory configuration."""
700
+ location = "memory"
701
+
702
+ if not isinstance(memory_config, dict):
703
+ result.add_error(
704
+ f"Memory must be a dictionary, got {type(memory_config).__name__}",
705
+ location,
706
+ "Use memory fields like enabled, conversation_memory, persistent_memory, etc.",
707
+ )
708
+ return
709
+
710
+ # Validate enabled if present
711
+ if "enabled" in memory_config:
712
+ enabled = memory_config["enabled"]
713
+ if not isinstance(enabled, bool):
714
+ result.add_error(
715
+ f"'enabled' must be a boolean, got {type(enabled).__name__}",
716
+ f"{location}.enabled",
717
+ "Use 'true' or 'false'",
718
+ )
719
+
720
+ # Validate conversation_memory if present
721
+ if "conversation_memory" in memory_config:
722
+ conv_memory = memory_config["conversation_memory"]
723
+ if not isinstance(conv_memory, dict):
724
+ result.add_error(
725
+ f"'conversation_memory' must be a dictionary, got {type(conv_memory).__name__}",
726
+ f"{location}.conversation_memory",
727
+ "Use 'enabled: true/false'",
728
+ )
729
+ elif "enabled" in conv_memory:
730
+ enabled = conv_memory["enabled"]
731
+ if not isinstance(enabled, bool):
732
+ result.add_error(
733
+ f"'enabled' must be a boolean, got {type(enabled).__name__}",
734
+ f"{location}.conversation_memory.enabled",
735
+ "Use 'true' or 'false'",
736
+ )
737
+
738
+ # Validate persistent_memory if present
739
+ if "persistent_memory" in memory_config:
740
+ persist_memory = memory_config["persistent_memory"]
741
+ if not isinstance(persist_memory, dict):
742
+ result.add_error(
743
+ f"'persistent_memory' must be a dictionary, got {type(persist_memory).__name__}",
744
+ f"{location}.persistent_memory",
745
+ "Use fields like enabled, on_disk, vector_store, etc.",
746
+ )
747
+ else:
748
+ # Validate boolean fields
749
+ boolean_fields = ["enabled", "on_disk"]
750
+ for field_name in boolean_fields:
751
+ if field_name in persist_memory:
752
+ value = persist_memory[field_name]
753
+ if not isinstance(value, bool):
754
+ result.add_error(
755
+ f"'{field_name}' must be a boolean, got {type(value).__name__}",
756
+ f"{location}.persistent_memory.{field_name}",
757
+ "Use 'true' or 'false'",
758
+ )
759
+
760
+ # Validate vector_store if present
761
+ if "vector_store" in persist_memory:
762
+ vector_store = persist_memory["vector_store"]
763
+ if not isinstance(vector_store, str):
764
+ result.add_error(
765
+ f"'vector_store' must be a string, got {type(vector_store).__name__}",
766
+ f"{location}.persistent_memory.vector_store",
767
+ "Use 'qdrant' or other vector store name",
768
+ )
769
+
770
+ # Validate llm config if present
771
+ if "llm" in persist_memory:
772
+ llm_config = persist_memory["llm"]
773
+ if not isinstance(llm_config, dict):
774
+ result.add_error(
775
+ f"'llm' must be a dictionary, got {type(llm_config).__name__}",
776
+ f"{location}.persistent_memory.llm",
777
+ "Use 'provider' and 'model' fields",
778
+ )
779
+ else:
780
+ # Check provider and model are strings
781
+ for field_name in ["provider", "model"]:
782
+ if field_name in llm_config:
783
+ value = llm_config[field_name]
784
+ if not isinstance(value, str):
785
+ result.add_error(
786
+ f"'{field_name}' must be a string, got {type(value).__name__}",
787
+ f"{location}.persistent_memory.llm.{field_name}",
788
+ "Use a string value",
789
+ )
790
+
791
+ # Validate embedding config if present
792
+ if "embedding" in persist_memory:
793
+ embedding_config = persist_memory["embedding"]
794
+ if not isinstance(embedding_config, dict):
795
+ result.add_error(
796
+ f"'embedding' must be a dictionary, got {type(embedding_config).__name__}",
797
+ f"{location}.persistent_memory.embedding",
798
+ "Use 'provider' and 'model' fields",
799
+ )
800
+ else:
801
+ # Check provider and model are strings
802
+ for field_name in ["provider", "model"]:
803
+ if field_name in embedding_config:
804
+ value = embedding_config[field_name]
805
+ if not isinstance(value, str):
806
+ result.add_error(
807
+ f"'{field_name}' must be a string, got {type(value).__name__}",
808
+ f"{location}.persistent_memory.embedding.{field_name}",
809
+ "Use a string value",
810
+ )
811
+
812
+ # Validate qdrant config if present
813
+ if "qdrant" in persist_memory:
814
+ qdrant_config = persist_memory["qdrant"]
815
+ if not isinstance(qdrant_config, dict):
816
+ result.add_error(
817
+ f"'qdrant' must be a dictionary, got {type(qdrant_config).__name__}",
818
+ f"{location}.persistent_memory.qdrant",
819
+ "Use 'mode', 'host', 'port' or 'path' fields",
820
+ )
821
+ else:
822
+ # Validate mode if present
823
+ if "mode" in qdrant_config:
824
+ mode = qdrant_config["mode"]
825
+ if mode not in ["server", "local"]:
826
+ result.add_error(
827
+ f"Invalid qdrant mode: '{mode}'",
828
+ f"{location}.persistent_memory.qdrant.mode",
829
+ "Use 'server' or 'local'",
830
+ )
831
+
832
+ # Validate port if present (for server mode)
833
+ if "port" in qdrant_config:
834
+ port = qdrant_config["port"]
835
+ if not isinstance(port, int) or port <= 0 or port > 65535:
836
+ result.add_error(
837
+ "'port' must be a valid port number (1-65535)",
838
+ f"{location}.persistent_memory.qdrant.port",
839
+ "Use a port number like 6333",
840
+ )
841
+
842
+ # Validate compression if present
843
+ if "compression" in memory_config:
844
+ compression = memory_config["compression"]
845
+ if not isinstance(compression, dict):
846
+ result.add_error(
847
+ f"'compression' must be a dictionary, got {type(compression).__name__}",
848
+ f"{location}.compression",
849
+ "Use 'trigger_threshold' and 'target_ratio' fields",
850
+ )
851
+ else:
852
+ # Validate threshold values (should be between 0 and 1)
853
+ for field_name in ["trigger_threshold", "target_ratio"]:
854
+ if field_name in compression:
855
+ value = compression[field_name]
856
+ if not isinstance(value, (int, float)):
857
+ result.add_error(
858
+ f"'{field_name}' must be a number, got {type(value).__name__}",
859
+ f"{location}.compression.{field_name}",
860
+ "Use a decimal value between 0 and 1",
861
+ )
862
+ elif not 0 <= value <= 1:
863
+ result.add_error(
864
+ f"'{field_name}' must be between 0 and 1, got {value}",
865
+ f"{location}.compression.{field_name}",
866
+ "Use a decimal value between 0 and 1 (e.g., 0.75 for 75%)",
867
+ )
868
+
869
+ # Validate retrieval if present
870
+ if "retrieval" in memory_config:
871
+ retrieval = memory_config["retrieval"]
872
+ if not isinstance(retrieval, dict):
873
+ result.add_error(
874
+ f"'retrieval' must be a dictionary, got {type(retrieval).__name__}",
875
+ f"{location}.retrieval",
876
+ "Use 'limit' and 'exclude_recent' fields",
877
+ )
878
+ else:
879
+ # Validate limit if present
880
+ if "limit" in retrieval:
881
+ limit = retrieval["limit"]
882
+ if not isinstance(limit, int) or limit <= 0:
883
+ result.add_error(
884
+ "'limit' must be a positive integer",
885
+ f"{location}.retrieval.limit",
886
+ "Use a value like 5 or 10",
887
+ )
888
+
889
+ # Validate exclude_recent if present
890
+ if "exclude_recent" in retrieval:
891
+ exclude_recent = retrieval["exclude_recent"]
892
+ if not isinstance(exclude_recent, bool):
893
+ result.add_error(
894
+ f"'exclude_recent' must be a boolean, got {type(exclude_recent).__name__}",
895
+ f"{location}.retrieval.exclude_recent",
896
+ "Use 'true' or 'false'",
897
+ )
898
+
899
+ def _check_warnings(self, config: Dict[str, Any], result: ValidationResult) -> None:
900
+ """Check for warnings (best practices, deprecations, etc.)."""
901
+ # Get agents list (normalize single agent to list)
902
+ if "agents" in config:
903
+ agents = config["agents"]
904
+ if not isinstance(agents, list):
905
+ return
906
+ elif "agent" in config:
907
+ agents = [config["agent"]]
908
+ else:
909
+ return
910
+
911
+ # Check each agent's backend for warnings
912
+ for i, agent_config in enumerate(agents):
913
+ if not isinstance(agent_config, dict) or "backend" not in agent_config:
914
+ continue
915
+
916
+ agent_location = f"agents[{i}]" if "agents" in config else "agent"
917
+ backend_config = agent_config["backend"]
918
+
919
+ if not isinstance(backend_config, dict):
920
+ continue
921
+
922
+ # Warning: Using both allowed_tools and exclude_tools
923
+ if "allowed_tools" in backend_config and "exclude_tools" in backend_config:
924
+ result.add_warning(
925
+ "Using both 'allowed_tools' and 'exclude_tools' can be confusing",
926
+ f"{agent_location}.backend",
927
+ "Prefer using only 'allowed_tools' (explicit allowlist) or 'exclude_tools' (denylist)",
928
+ )
929
+
930
+ # Warning: Check for deprecated fields (add as needed)
931
+ # This is a placeholder for future deprecations