devcouncil 0.1.1 → 0.3.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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,43 +1,237 @@
1
- from typing import List, Dict, Any, Type, Optional
1
+ from typing import List, Dict, Any, Type, Optional, TypeVar
2
2
  import copy
3
+ import functools
3
4
  import json
4
5
  import logging
5
6
  import asyncio
7
+ import time
6
8
  from pathlib import Path
7
9
 
8
10
  from pydantic import BaseModel
9
- from devcouncil.llm.provider import Provider
11
+ from devcouncil.llm.provider import Provider, LLMResponse
10
12
  from devcouncil.llm.cache import LLMCache
11
13
  from devcouncil.telemetry.tracker import TelemetryTracker
14
+ from devcouncil.telemetry.traces import TraceLogger
12
15
 
13
16
  logger = logging.getLogger(__name__)
17
+ StructuredModel = TypeVar("StructuredModel", bound=BaseModel)
18
+
19
+
20
+ @functools.lru_cache(maxsize=128)
21
+ def _cached_schema_json(schema_class) -> str:
22
+ return json.dumps(schema_class.model_json_schema(), indent=2)
23
+
24
+
25
+ class StructuredOutputError(RuntimeError):
26
+ """A model could not produce valid structured output for a role, even after
27
+ a healing retry. Carries the role/model so the CLI can give actionable advice
28
+ (usually: switch that role to a more capable model)."""
29
+
30
+ def __init__(self, message: str, *, role: str, model: str):
31
+ super().__init__(message)
32
+ self.role = role
33
+ self.model = model
34
+
14
35
 
15
36
  class ModelRouter:
16
- def __init__(self, provider: Provider, role_config: Dict[str, Dict[str, Any]]):
37
+ # Independent fresh attempts at producing valid structured output before
38
+ # giving up. Even capable models occasionally emit malformed JSON; a second
39
+ # clean attempt usually succeeds. Malformed responses are never cached, so a
40
+ # retry is genuinely fresh rather than re-serving the same bad JSON.
41
+ STRUCTURED_ATTEMPTS = 2
42
+
43
+ def __init__(
44
+ self,
45
+ provider: Provider,
46
+ role_config: Dict[str, Dict[str, Any]],
47
+ project_root: Path = Path("."),
48
+ ):
17
49
  self.provider = provider
18
50
  self.role_config = role_config
51
+ self.project_root = project_root
52
+ # LLMCache and TraceLogger do disk I/O (mkdir) in their constructors, so build
53
+ # them once here and reuse across calls. TelemetryTracker is deliberately *not*
54
+ # hoisted: it is constructed per-call so log_usage's reload-before-save stays
55
+ # concurrent-write safe.
56
+ self._cache = LLMCache(self.project_root)
57
+ self._traces = TraceLogger(self.project_root)
58
+ # Lazily-built providers for roles that override ``models.provider`` with
59
+ # their own ``provider:`` (e.g. live_reviewer on Ollama while planners run
60
+ # on OpenRouter). Keyed by normalized provider name; the default provider
61
+ # passed in above is reused for roles without an override.
62
+ self._role_providers: Dict[str, Provider] = {}
63
+
64
+ def _provider_for_role(self, role_config: Dict[str, Any]) -> Provider:
65
+ """Resolve the provider for a role, honoring a per-role ``provider`` override.
66
+
67
+ Roles without an override use the default provider supplied at construction.
68
+ Overriding roles get a provider built on demand (and cached) from the
69
+ configured credentials, so one router can fan a single run across multiple
70
+ providers."""
71
+ role_provider = role_config.get("provider")
72
+ if not role_provider:
73
+ return self.provider
74
+ # Local imports avoid a circular import at module load (provider/config
75
+ # both reference this package).
76
+ from devcouncil.llm.provider import create_provider, validate_model_provider
77
+ from devcouncil.app.config import get_api_key
78
+
79
+ normalized = validate_model_provider(role_provider)
80
+ if normalized not in self._role_providers:
81
+ api_key = get_api_key(normalized, self.project_root)
82
+ # An override to OpenRouter must still honor the project's provider-routing
83
+ # prefs (sort/allow_fallbacks/data_collection); other providers ignore them.
84
+ # Best-effort: a missing/invalid config just yields default routing.
85
+ prefs = None
86
+ if normalized == "openrouter":
87
+ try:
88
+ from devcouncil.app.config import load_config
89
+
90
+ prefs = load_config(self.project_root).provider
91
+ except Exception:
92
+ prefs = None
93
+ self._role_providers[normalized] = create_provider(
94
+ normalized, api_key, project_root=self.project_root, provider_prefs=prefs
95
+ )
96
+ return self._role_providers[normalized]
97
+
98
+ @staticmethod
99
+ def _extract_json(content: str) -> str:
100
+ """Best-effort extraction of a JSON document from a model response.
101
+
102
+ Handles the common ways a model wraps valid JSON: triple-backtick fences and
103
+ surrounding prose ("Here you go: {...} thanks"). Strips fences, returns the
104
+ whole thing if it already parses, otherwise scans for the first balanced
105
+ object/array (string- and escape-aware so braces inside string values don't
106
+ confuse it). Falls back to the de-fenced text so the existing healing path
107
+ still produces a meaningful error. A strict superset of plain fence-stripping
108
+ — clean/fenced JSON is returned unchanged."""
109
+ text = content.strip()
110
+ if "```json" in text:
111
+ text = text.split("```json", 1)[1].split("```", 1)[0].strip()
112
+ elif "```" in text:
113
+ text = text.split("```", 1)[1].split("```", 1)[0].strip()
114
+ try:
115
+ json.loads(text)
116
+ return text
117
+ except Exception:
118
+ pass
119
+ for opener, closer in (("{", "}"), ("[", "]")):
120
+ start = text.find(opener)
121
+ if start == -1:
122
+ continue
123
+ depth = 0
124
+ in_str = False
125
+ escaped = False
126
+ for i in range(start, len(text)):
127
+ ch = text[i]
128
+ if in_str:
129
+ if escaped:
130
+ escaped = False
131
+ elif ch == "\\":
132
+ escaped = True
133
+ elif ch == '"':
134
+ in_str = False
135
+ continue
136
+ if ch == '"':
137
+ in_str = True
138
+ elif ch == opener:
139
+ depth += 1
140
+ elif ch == closer:
141
+ depth -= 1
142
+ if depth == 0:
143
+ candidate = text[start:i + 1]
144
+ try:
145
+ json.loads(candidate)
146
+ return candidate
147
+ except Exception:
148
+ break
149
+ return text
150
+
151
+ @staticmethod
152
+ def _looks_like_schema_echo(text: str) -> bool:
153
+ """True when the model returned the JSON *schema* instead of an instance.
154
+
155
+ Weaker/local models sometimes parrot the schema document we showed them
156
+ (``{"$defs": ..., "properties": ..., "type": "object"}``). That parses as JSON
157
+ but never validates, so detecting it lets the healing retry give a pointed
158
+ correction instead of the generic "fix your JSON" nudge."""
159
+ try:
160
+ obj = json.loads(text)
161
+ except Exception:
162
+ return False
163
+ if not isinstance(obj, dict):
164
+ return False
165
+ markers = {"$schema", "$defs", "properties", "additionalProperties", "$ref"}
166
+ return bool(markers & set(obj.keys()))
167
+
168
+ async def _complete_with_retry(
169
+ self,
170
+ *,
171
+ model: str,
172
+ messages: List[Dict[str, str]],
173
+ temperature: float,
174
+ run_id: Optional[str],
175
+ provider: Optional[Provider] = None,
176
+ attempts: int = 3,
177
+ ) -> "LLMResponse":
178
+ """Provider completion with bounded exponential-backoff retry. Used for BOTH the
179
+ initial call and the healing call so a transient fault in either is retried (and,
180
+ if still failing, surfaced to the caller's fallback logic) rather than aborting
181
+ the run. ``provider`` defaults to the router's default provider but may be a
182
+ per-role provider for roles that override ``models.provider``."""
183
+ provider = provider or self.provider
184
+ for attempt in range(attempts):
185
+ try:
186
+ return await provider.complete(
187
+ model=model,
188
+ messages=messages,
189
+ temperature=temperature,
190
+ json_mode=True,
191
+ run_id=run_id,
192
+ )
193
+ except Exception as exc:
194
+ if attempt == attempts - 1:
195
+ raise
196
+ logger.warning(
197
+ "LLM request failed (attempt %d/%d): %s. Retrying...",
198
+ attempt + 1, attempts, exc,
199
+ )
200
+ await asyncio.sleep(2 ** attempt)
201
+ raise RuntimeError("unreachable") # loop either returns or raises
19
202
 
20
203
  async def complete_structured(
21
204
  self,
22
205
  role: str,
23
206
  messages: List[Dict[str, str]],
24
- schema: Type[BaseModel],
207
+ schema: Type[StructuredModel],
25
208
  temperature: Optional[float] = None,
26
209
  run_id: Optional[str] = None,
27
- ) -> BaseModel:
210
+ fallback: Optional[StructuredModel] = None,
211
+ _attempt: int = 0,
212
+ ) -> StructuredModel:
28
213
  config = self.role_config.get(role)
29
214
  if not config:
30
215
  raise ValueError(f"No config found for role: {role}")
31
-
216
+
32
217
  model = config["model"]
33
218
  temp = temperature if temperature is not None else config.get("temperature", 0.0)
219
+ provider = self._provider_for_role(config)
34
220
 
35
221
  # Deep-copy to avoid mutating the caller's messages list
36
222
  msgs = copy.deepcopy(messages)
37
223
 
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}"
224
+ # Add schema instructions to system or user message. Spell out "instance, not
225
+ # the schema" explicitly: weaker/local models otherwise sometimes echo the schema
226
+ # document back (``{"$defs": ..., "properties": ..., "type": "object"}``), which
227
+ # parses as JSON but fails validation and wastes a healing round.
228
+ schema_json = _cached_schema_json(schema)
229
+ instruction = (
230
+ "\n\nYou MUST output a single JSON object that is an INSTANCE of this schema — "
231
+ "real values for each field. Do NOT output the schema itself; never include "
232
+ 'keys like "$defs", "$schema", "properties", or "type".\nSchema:\n'
233
+ f"{schema_json}"
234
+ )
41
235
 
42
236
  found_system = False
43
237
  for msg in msgs:
@@ -51,75 +245,165 @@ class ModelRouter:
51
245
 
52
246
  logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
53
247
 
54
- project_root = Path(".")
55
- cache = LLMCache(project_root)
56
- tracker = TelemetryTracker(project_root)
248
+ cache = self._cache
249
+ tracker = TelemetryTracker(self.project_root)
250
+ traces = self._traces
251
+
252
+ # Provider knobs (e.g. Ollama num_ctx / base_url) that change the output for an
253
+ # identical prompt must be part of the cache key, else raising OLLAMA_NUM_CTX
254
+ # after a truncated answer would keep serving the stale response.
255
+ provider_fp = provider.cache_fingerprint()
256
+ # Zero local (Ollama) usage by provider so telemetry matches the cost ledger.
257
+ provider_local = provider.is_local_cost_free()
57
258
 
58
259
  # Check cache first
59
- response = cache.get(model, msgs, temp, True)
260
+ response = cache.get(model, msgs, temp, True, provider_fp)
60
261
  cache_hit = response is not None
61
262
 
263
+ started = time.monotonic()
62
264
  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)
265
+ response = await self._complete_with_retry(
266
+ model=model, messages=msgs, temperature=temp, run_id=run_id, provider=provider
267
+ )
268
+ elapsed = time.monotonic() - started
269
+
270
+ if response is None:
271
+ raise RuntimeError(f"LLM request for role {role} did not return a response.")
78
272
 
79
273
  if not cache_hit:
80
- tracker.log_usage(model, response.usage)
274
+ tracker.log_usage(model, response.usage, local=provider_local)
81
275
 
276
+ # Include latency + cache status: on a slow (e.g. local) model this is what tells
277
+ # you *which* call dominated a multi-minute planning/verification stage.
82
278
  logger.info(
83
- "LLM response: role=%s model=%s tokens=%s",
279
+ "LLM response: role=%s model=%s tokens=%s %s",
84
280
  role, response.model, response.usage,
281
+ "cache_hit" if cache_hit else f"{elapsed:.1f}s",
85
282
  )
86
283
 
87
284
  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
-
285
+ # Extract JSON from fences/surrounding prose (balanced-aware).
286
+ content = self._extract_json(response.content)
95
287
  data = json.loads(content)
96
- return schema.model_validate(data)
288
+ result = schema.model_validate(data)
289
+ if not cache_hit:
290
+ cache.set(model, msgs, temp, True, response, provider_fp) # cache only validated output
291
+ return result
97
292
  except Exception as e:
98
293
  logger.warning(f"Initial parse failed for {role}, attempting healing: {e}")
294
+ traces.log_event(
295
+ "llm_structured_parse_failed",
296
+ {
297
+ "role": role,
298
+ "model": response.model,
299
+ "schema": schema.__name__,
300
+ "error": str(e),
301
+ "content_preview": response.content[:500],
302
+ },
303
+ run_id=run_id,
304
+ summary=f"Structured response parse failed for {role}; attempting repair.",
305
+ )
99
306
 
100
- # Healing attempt: Ask the model to fix its own JSON
307
+ # Healing attempt: Ask the model to fix its own JSON. If it echoed the schema
308
+ # back instead of an instance, say so explicitly — the generic "fix it" nudge
309
+ # otherwise tends to produce the schema again.
310
+ echo_hint = ""
311
+ if self._looks_like_schema_echo(self._extract_json(response.content)):
312
+ echo_hint = (
313
+ "\nIMPORTANT: You returned the JSON *schema* (it contains keys like "
314
+ '"$defs"/"properties"/"type"), not a value. Return a concrete INSTANCE: '
315
+ "a JSON object whose keys are the schema's property names, each with a "
316
+ "real value of the correct type."
317
+ )
101
318
  healing_prompt = f"""
102
319
  The following JSON was returned but failed to parse or validate against the schema.
103
320
  Error: {str(e)}
104
321
  Content:
105
322
  {response.content}
106
-
323
+ {echo_hint}
107
324
  Please return the corrected JSON object only. No prose.
108
325
  """
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
-
326
+ # The healing completion runs INSIDE this try (with the same retry/backoff as
327
+ # the initial call). A transient failure here (429/timeout) must be treated as
328
+ # "healing failed" so it routes into the fresh-attempt/fallback logic below,
329
+ # not propagate as a raw provider error that defeats the supplied fallback.
330
+ healed_response = None
117
331
  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()
332
+ # We use a lower temperature for healing
333
+ healed_response = await self._complete_with_retry(
334
+ model=model,
335
+ messages=[{"role": "user", "content": healing_prompt}],
336
+ temperature=0.0,
337
+ run_id=run_id,
338
+ provider=provider,
339
+ )
340
+ tracker.log_usage(healed_response.model, healed_response.usage, local=provider_local)
341
+ healed_content = self._extract_json(healed_response.content)
121
342
  data = json.loads(healed_content)
122
- return schema.model_validate(data)
343
+ result = schema.model_validate(data)
344
+ cache.set(model, msgs, temp, True, healed_response, provider_fp)
345
+ return result
123
346
  except Exception as final_e:
124
347
  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]}...")
348
+ traces.log_event(
349
+ "llm_structured_parse_repair_failed",
350
+ {
351
+ "role": role,
352
+ "model": healed_response.model if healed_response else model,
353
+ "schema": schema.__name__,
354
+ "error": str(final_e),
355
+ "original_content_preview": response.content[:500],
356
+ "healed_content_preview": healed_response.content[:500] if healed_response else "(healing request failed)",
357
+ },
358
+ run_id=run_id,
359
+ summary=f"Structured response repair failed for {role}.",
360
+ )
361
+ if _attempt + 1 < self.STRUCTURED_ATTEMPTS:
362
+ # A fresh, independent attempt often succeeds where one bad draft
363
+ # (plus its repair) failed. The malformed response was never
364
+ # cached, so this re-runs the completion rather than re-reading it.
365
+ # Prepend a strict JSON-only instruction (on a copy, so the caller's
366
+ # messages are untouched) to nudge the retry toward parseable output.
367
+ logger.warning(
368
+ "Structured output failed for role '%s'; retrying fresh "
369
+ "(attempt %d/%d).",
370
+ role, _attempt + 2, self.STRUCTURED_ATTEMPTS,
371
+ )
372
+ strict_messages = [
373
+ {
374
+ "role": "system",
375
+ "content": (
376
+ "Respond with a single valid JSON object only — no prose, no "
377
+ "markdown fences, no trailing text. It must parse with a strict "
378
+ "JSON parser and match the requested schema."
379
+ ),
380
+ },
381
+ *messages,
382
+ ]
383
+ return await self.complete_structured(
384
+ role,
385
+ strict_messages,
386
+ schema,
387
+ temperature=temperature,
388
+ run_id=run_id,
389
+ fallback=fallback,
390
+ _attempt=_attempt + 1,
391
+ )
392
+ if fallback is not None:
393
+ # Degradable role (e.g. critique/rebuttal/enhancement): keep
394
+ # planning alive on weaker models instead of crashing the run.
395
+ logger.warning(
396
+ "Role '%s' (model '%s') could not produce valid %s; "
397
+ "using a safe fallback so planning can continue.",
398
+ role, model, schema.__name__,
399
+ )
400
+ return fallback
401
+ raise StructuredOutputError(
402
+ f"Model '{model}' for role '{role}' could not produce valid "
403
+ f"{schema.__name__} JSON, even after a repair attempt. "
404
+ f"Use a more capable model for this role "
405
+ f"(e.g. 'dev config models --role {role} --model <model>'). "
406
+ f"Parser error: {final_e}",
407
+ role=role,
408
+ model=model,
409
+ )
@@ -0,0 +1 @@
1
+ """Optimization integrations for DevCouncil prompt and workflow assets."""