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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- 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 +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- 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 +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- 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 +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
89
|
-
content = response.content
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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."""
|