devcouncil 0.1.0 → 0.2.0

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.
Files changed (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,125 +1,310 @@
1
- from typing import List, Dict, Any, Type, Optional
2
- import copy
3
- import json
4
- import logging
5
- import asyncio
6
- from pathlib import Path
7
-
8
- from pydantic import BaseModel
9
- from devcouncil.llm.provider import Provider
10
- from devcouncil.llm.cache import LLMCache
11
- from devcouncil.telemetry.tracker import TelemetryTracker
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
- class ModelRouter:
16
- def __init__(self, provider: Provider, role_config: Dict[str, Dict[str, Any]]):
17
- self.provider = provider
18
- self.role_config = role_config
19
-
20
- async def complete_structured(
21
- self,
22
- role: str,
23
- messages: List[Dict[str, str]],
24
- schema: Type[BaseModel],
25
- temperature: Optional[float] = None,
26
- run_id: Optional[str] = None,
27
- ) -> BaseModel:
28
- config = self.role_config.get(role)
29
- if not config:
30
- raise ValueError(f"No config found for role: {role}")
31
-
32
- model = config["model"]
33
- temp = temperature if temperature is not None else config.get("temperature", 0.0)
34
-
35
- # Deep-copy to avoid mutating the caller's messages list
36
- msgs = copy.deepcopy(messages)
37
-
38
- # Add schema instructions to system or user message
39
- schema_json = json.dumps(schema.model_json_schema(), indent=2)
40
- instruction = f"\n\nYou MUST output a JSON object matching this schema:\n{schema_json}"
41
-
42
- found_system = False
43
- for msg in msgs:
44
- if msg["role"] == "system":
45
- msg["content"] += instruction
46
- found_system = True
47
- break
48
-
49
- if not found_system:
50
- msgs.insert(0, {"role": "system", "content": f"You are a helpful assistant.{instruction}"})
51
-
52
- logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
53
-
54
- project_root = Path(".")
55
- cache = LLMCache(project_root)
56
- tracker = TelemetryTracker(project_root)
1
+ from typing import List, Dict, Any, Type, Optional, TypeVar
2
+ import copy
3
+ import json
4
+ import logging
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+ from devcouncil.llm.provider import Provider, LLMResponse
10
+ from devcouncil.llm.cache import LLMCache
11
+ from devcouncil.telemetry.tracker import TelemetryTracker
12
+ from devcouncil.telemetry.traces import TraceLogger
13
+
14
+ logger = logging.getLogger(__name__)
15
+ StructuredModel = TypeVar("StructuredModel", bound=BaseModel)
16
+
17
+
18
+ class StructuredOutputError(RuntimeError):
19
+ """A model could not produce valid structured output for a role, even after
20
+ a healing retry. Carries the role/model so the CLI can give actionable advice
21
+ (usually: switch that role to a more capable model)."""
22
+
23
+ def __init__(self, message: str, *, role: str, model: str):
24
+ super().__init__(message)
25
+ self.role = role
26
+ self.model = model
27
+
28
+
29
+ class ModelRouter:
30
+ # Independent fresh attempts at producing valid structured output before
31
+ # giving up. Even capable models occasionally emit malformed JSON; a second
32
+ # clean attempt usually succeeds. Malformed responses are never cached, so a
33
+ # retry is genuinely fresh rather than re-serving the same bad JSON.
34
+ STRUCTURED_ATTEMPTS = 2
35
+
36
+ def __init__(
37
+ self,
38
+ provider: Provider,
39
+ role_config: Dict[str, Dict[str, Any]],
40
+ project_root: Path = Path("."),
41
+ ):
42
+ self.provider = provider
43
+ self.role_config = role_config
44
+ self.project_root = project_root
45
+
46
+ @staticmethod
47
+ def _extract_json(content: str) -> str:
48
+ """Best-effort extraction of a JSON document from a model response.
49
+
50
+ Handles the common ways a model wraps valid JSON: triple-backtick fences and
51
+ surrounding prose ("Here you go: {...} thanks"). Strips fences, returns the
52
+ whole thing if it already parses, otherwise scans for the first balanced
53
+ object/array (string- and escape-aware so braces inside string values don't
54
+ confuse it). Falls back to the de-fenced text so the existing healing path
55
+ still produces a meaningful error. A strict superset of plain fence-stripping
56
+ clean/fenced JSON is returned unchanged."""
57
+ text = content.strip()
58
+ if "```json" in text:
59
+ text = text.split("```json", 1)[1].split("```", 1)[0].strip()
60
+ elif "```" in text:
61
+ text = text.split("```", 1)[1].split("```", 1)[0].strip()
62
+ try:
63
+ json.loads(text)
64
+ return text
65
+ except Exception:
66
+ pass
67
+ for opener, closer in (("{", "}"), ("[", "]")):
68
+ start = text.find(opener)
69
+ if start == -1:
70
+ continue
71
+ depth = 0
72
+ in_str = False
73
+ escaped = False
74
+ for i in range(start, len(text)):
75
+ ch = text[i]
76
+ if in_str:
77
+ if escaped:
78
+ escaped = False
79
+ elif ch == "\\":
80
+ escaped = True
81
+ elif ch == '"':
82
+ in_str = False
83
+ continue
84
+ if ch == '"':
85
+ in_str = True
86
+ elif ch == opener:
87
+ depth += 1
88
+ elif ch == closer:
89
+ depth -= 1
90
+ if depth == 0:
91
+ candidate = text[start:i + 1]
92
+ try:
93
+ json.loads(candidate)
94
+ return candidate
95
+ except Exception:
96
+ break
97
+ return text
98
+
99
+ async def _complete_with_retry(
100
+ self,
101
+ *,
102
+ model: str,
103
+ messages: List[Dict[str, str]],
104
+ temperature: float,
105
+ run_id: Optional[str],
106
+ attempts: int = 3,
107
+ ) -> "LLMResponse":
108
+ """Provider completion with bounded exponential-backoff retry. Used for BOTH the
109
+ initial call and the healing call so a transient fault in either is retried (and,
110
+ if still failing, surfaced to the caller's fallback logic) rather than aborting
111
+ the run."""
112
+ for attempt in range(attempts):
113
+ try:
114
+ return await self.provider.complete(
115
+ model=model,
116
+ messages=messages,
117
+ temperature=temperature,
118
+ json_mode=True,
119
+ run_id=run_id,
120
+ )
121
+ except Exception as exc:
122
+ if attempt == attempts - 1:
123
+ raise
124
+ logger.warning(
125
+ "LLM request failed (attempt %d/%d): %s. Retrying...",
126
+ attempt + 1, attempts, exc,
127
+ )
128
+ await asyncio.sleep(2 ** attempt)
129
+ raise RuntimeError("unreachable") # loop either returns or raises
130
+
131
+ async def complete_structured(
132
+ self,
133
+ role: str,
134
+ messages: List[Dict[str, str]],
135
+ schema: Type[StructuredModel],
136
+ temperature: Optional[float] = None,
137
+ run_id: Optional[str] = None,
138
+ fallback: Optional[StructuredModel] = None,
139
+ _attempt: int = 0,
140
+ ) -> StructuredModel:
141
+ config = self.role_config.get(role)
142
+ if not config:
143
+ raise ValueError(f"No config found for role: {role}")
144
+
145
+ model = config["model"]
146
+ temp = temperature if temperature is not None else config.get("temperature", 0.0)
147
+
148
+ # Deep-copy to avoid mutating the caller's messages list
149
+ msgs = copy.deepcopy(messages)
150
+
151
+ # Add schema instructions to system or user message
152
+ schema_json = json.dumps(schema.model_json_schema(), indent=2)
153
+ instruction = f"\n\nYou MUST output a JSON object matching this schema:\n{schema_json}"
154
+
155
+ found_system = False
156
+ for msg in msgs:
157
+ if msg["role"] == "system":
158
+ msg["content"] += instruction
159
+ found_system = True
160
+ break
161
+
162
+ if not found_system:
163
+ msgs.insert(0, {"role": "system", "content": f"You are a helpful assistant.{instruction}"})
164
+
165
+ logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
166
+
167
+ cache = LLMCache(self.project_root)
168
+ tracker = TelemetryTracker(self.project_root)
169
+ traces = TraceLogger(self.project_root)
170
+
171
+ # Provider knobs (e.g. Ollama num_ctx / base_url) that change the output for an
172
+ # identical prompt must be part of the cache key, else raising OLLAMA_NUM_CTX
173
+ # after a truncated answer would keep serving the stale response.
174
+ provider_fp = self.provider.cache_fingerprint()
175
+ # Zero local (Ollama) usage by provider so telemetry matches the cost ledger.
176
+ provider_local = self.provider.is_local_cost_free()
57
177
 
58
178
  # Check cache first
59
- response = cache.get(model, msgs, temp, True)
179
+ response = cache.get(model, msgs, temp, True, provider_fp)
60
180
  cache_hit = response is not None
61
181
 
62
182
  if not response:
63
- for attempt in range(3):
64
- try:
65
- response = await self.provider.complete(
66
- model=model,
67
- messages=msgs,
68
- temperature=temp,
69
- json_mode=True
70
- )
71
- cache.set(model, msgs, temp, True, response)
72
- break
73
- except Exception as e:
74
- if attempt == 2:
75
- raise
76
- logger.warning(f"LLM request failed (attempt {attempt+1}): {e}. Retrying...")
77
- await asyncio.sleep(2 ** attempt)
183
+ response = await self._complete_with_retry(
184
+ model=model, messages=msgs, temperature=temp, run_id=run_id
185
+ )
186
+
187
+ if response is None:
188
+ raise RuntimeError(f"LLM request for role {role} did not return a response.")
78
189
 
79
190
  if not cache_hit:
80
- tracker.log_usage(model, response.usage)
81
-
82
- logger.info(
83
- "LLM response: role=%s model=%s tokens=%s",
84
- role, response.model, response.usage,
85
- )
86
-
87
- try:
88
- # Attempt to find JSON block if it's wrapped in markdown
89
- content = response.content.strip()
90
- if "```json" in content:
91
- content = content.split("```json")[1].split("```")[0].strip()
92
- elif "```" in content:
93
- content = content.split("```")[1].split("```")[0].strip()
94
-
95
- data = json.loads(content)
96
- return schema.model_validate(data)
97
- except Exception as e:
98
- logger.warning(f"Initial parse failed for {role}, attempting healing: {e}")
99
-
100
- # Healing attempt: Ask the model to fix its own JSON
101
- healing_prompt = f"""
102
- The following JSON was returned but failed to parse or validate against the schema.
103
- Error: {str(e)}
104
- Content:
105
- {response.content}
106
-
107
- Please return the corrected JSON object only. No prose.
108
- """
109
- # We use a lower temperature for healing
110
- healed_response = await self.provider.complete(
111
- model=model,
112
- messages=[{"role": "user", "content": healing_prompt}],
113
- temperature=0.0,
114
- json_mode=True
115
- )
116
-
117
- try:
118
- healed_content = healed_response.content.strip()
119
- if "```json" in healed_content:
120
- healed_content = healed_content.split("```json")[1].split("```")[0].strip()
121
- data = json.loads(healed_content)
122
- return schema.model_validate(data)
123
- except Exception as final_e:
124
- logger.error(f"Healing failed for {role}: {final_e}")
125
- raise ValueError(f"Failed to parse or validate LLM response after healing: {final_e}\nContent (truncated): {response.content[:200]}...")
191
+ tracker.log_usage(model, response.usage, local=provider_local)
192
+
193
+ logger.info(
194
+ "LLM response: role=%s model=%s tokens=%s",
195
+ role, response.model, response.usage,
196
+ )
197
+
198
+ try:
199
+ # Extract JSON from fences/surrounding prose (balanced-aware).
200
+ content = self._extract_json(response.content)
201
+ data = json.loads(content)
202
+ result = schema.model_validate(data)
203
+ if not cache_hit:
204
+ cache.set(model, msgs, temp, True, response, provider_fp) # cache only validated output
205
+ return result
206
+ except Exception as e:
207
+ logger.warning(f"Initial parse failed for {role}, attempting healing: {e}")
208
+ traces.log_event(
209
+ "llm_structured_parse_failed",
210
+ {
211
+ "role": role,
212
+ "model": response.model,
213
+ "schema": schema.__name__,
214
+ "error": str(e),
215
+ "content_preview": response.content[:500],
216
+ },
217
+ run_id=run_id,
218
+ summary=f"Structured response parse failed for {role}; attempting repair.",
219
+ )
220
+
221
+ # Healing attempt: Ask the model to fix its own JSON
222
+ healing_prompt = f"""
223
+ The following JSON was returned but failed to parse or validate against the schema.
224
+ Error: {str(e)}
225
+ Content:
226
+ {response.content}
227
+
228
+ Please return the corrected JSON object only. No prose.
229
+ """
230
+ # The healing completion runs INSIDE this try (with the same retry/backoff as
231
+ # the initial call). A transient failure here (429/timeout) must be treated as
232
+ # "healing failed" so it routes into the fresh-attempt/fallback logic below,
233
+ # not propagate as a raw provider error that defeats the supplied fallback.
234
+ healed_response = None
235
+ try:
236
+ # We use a lower temperature for healing
237
+ healed_response = await self._complete_with_retry(
238
+ model=model,
239
+ messages=[{"role": "user", "content": healing_prompt}],
240
+ temperature=0.0,
241
+ run_id=run_id,
242
+ )
243
+ tracker.log_usage(healed_response.model, healed_response.usage, local=provider_local)
244
+ healed_content = self._extract_json(healed_response.content)
245
+ data = json.loads(healed_content)
246
+ result = schema.model_validate(data)
247
+ cache.set(model, msgs, temp, True, healed_response, provider_fp)
248
+ return result
249
+ except Exception as final_e:
250
+ logger.error(f"Healing failed for {role}: {final_e}")
251
+ traces.log_event(
252
+ "llm_structured_parse_repair_failed",
253
+ {
254
+ "role": role,
255
+ "model": healed_response.model if healed_response else model,
256
+ "schema": schema.__name__,
257
+ "error": str(final_e),
258
+ "original_content_preview": response.content[:500],
259
+ "healed_content_preview": healed_response.content[:500] if healed_response else "(healing request failed)",
260
+ },
261
+ run_id=run_id,
262
+ summary=f"Structured response repair failed for {role}.",
263
+ )
264
+ if _attempt + 1 < self.STRUCTURED_ATTEMPTS:
265
+ # A fresh, independent attempt often succeeds where one bad draft
266
+ # (plus its repair) failed. The malformed response was never
267
+ # cached, so this re-runs the completion rather than re-reading it.
268
+ # Prepend a strict JSON-only instruction (on a copy, so the caller's
269
+ # messages are untouched) to nudge the retry toward parseable output.
270
+ logger.warning(
271
+ "Structured output failed for role '%s'; retrying fresh "
272
+ "(attempt %d/%d).",
273
+ role, _attempt + 2, self.STRUCTURED_ATTEMPTS,
274
+ )
275
+ strict_messages = copy.deepcopy(messages)
276
+ strict_messages.insert(0, {
277
+ "role": "system",
278
+ "content": (
279
+ "Respond with a single valid JSON object only — no prose, no "
280
+ "markdown fences, no trailing text. It must parse with a strict "
281
+ "JSON parser and match the requested schema."
282
+ ),
283
+ })
284
+ return await self.complete_structured(
285
+ role,
286
+ strict_messages,
287
+ schema,
288
+ temperature=temperature,
289
+ run_id=run_id,
290
+ fallback=fallback,
291
+ _attempt=_attempt + 1,
292
+ )
293
+ if fallback is not None:
294
+ # Degradable role (e.g. critique/rebuttal/enhancement): keep
295
+ # planning alive on weaker models instead of crashing the run.
296
+ logger.warning(
297
+ "Role '%s' (model '%s') could not produce valid %s; "
298
+ "using a safe fallback so planning can continue.",
299
+ role, model, schema.__name__,
300
+ )
301
+ return fallback
302
+ raise StructuredOutputError(
303
+ f"Model '{model}' for role '{role}' could not produce valid "
304
+ f"{schema.__name__} JSON, even after a repair attempt. "
305
+ f"Use a more capable model for this role "
306
+ f"(e.g. 'dev config models --role {role} --model <model>'). "
307
+ f"Parser error: {final_e}",
308
+ role=role,
309
+ model=model,
310
+ )
@@ -0,0 +1 @@
1
+ """Optimization integrations for DevCouncil prompt and workflow assets."""