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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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
|
-
|
|
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[
|
|
207
|
+
schema: Type[StructuredModel],
|
|
25
208
|
temperature: Optional[float] = None,
|
|
26
209
|
run_id: Optional[str] = None,
|
|
27
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
89
|
-
content = response.content
|
|
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
|
-
|
|
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
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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."""
|