forgex-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/agents/builder.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Builder agent (spec section 9).
|
|
2
|
+
|
|
3
|
+
Receives task, plan, and research; implements task through the
|
|
4
|
+
permission-gated tool loop; reports changed files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from forgex.agents.base import BaseAgent, prefetch_files
|
|
15
|
+
from forgex.config.schema import ForgexConfig
|
|
16
|
+
from forgex.models.router import ModelRouter
|
|
17
|
+
from forgex.orchestration.checkpoints import create_checkpoint
|
|
18
|
+
from forgex.orchestration.events import EventRecorder
|
|
19
|
+
from forgex.orchestration.stores import ProjectStores
|
|
20
|
+
from forgex.research.citations import format_notes
|
|
21
|
+
from forgex.sandbox.permissions import permission_set_for
|
|
22
|
+
from forgex.schemas.agent import AgentRole, BuilderResult
|
|
23
|
+
from forgex.schemas.error import ErrorRecord, ProjectError
|
|
24
|
+
from forgex.schemas.event import EventType
|
|
25
|
+
from forgex.schemas.plan import ArchitecturePlan
|
|
26
|
+
from forgex.schemas.research import ResearchNote
|
|
27
|
+
from forgex.schemas.task import Task, TaskStatus
|
|
28
|
+
from forgex.tools import ToolExecutor, build_tool_registry, create_context
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("forgex.agents.builder")
|
|
31
|
+
|
|
32
|
+
MAX_PREFETCH_CHARS = 30_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BuilderAgent(BaseAgent):
|
|
36
|
+
role = AgentRole.BUILDER
|
|
37
|
+
prompt_name = "builder"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
router: ModelRouter,
|
|
43
|
+
tools: ToolExecutor,
|
|
44
|
+
events: EventRecorder | None = None,
|
|
45
|
+
run_id: str | None = None,
|
|
46
|
+
max_prefetch_chars: int = MAX_PREFETCH_CHARS,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(router=router, events=events, run_id=run_id)
|
|
49
|
+
self._tools = tools
|
|
50
|
+
self._max_prefetch_chars = max_prefetch_chars
|
|
51
|
+
|
|
52
|
+
def _prefetch_existing(self, task: Task) -> str:
|
|
53
|
+
workspace = self._tools.context.workspace
|
|
54
|
+
planned = prefetch_files(workspace, task.files, budget=self._max_prefetch_chars)
|
|
55
|
+
remaining = self._max_prefetch_chars - len(planned)
|
|
56
|
+
if remaining <= 0:
|
|
57
|
+
return planned
|
|
58
|
+
extra = self._related_context(
|
|
59
|
+
workspace,
|
|
60
|
+
query=task.description,
|
|
61
|
+
seeds=list(task.files),
|
|
62
|
+
budget=remaining,
|
|
63
|
+
exclude=set(task.files),
|
|
64
|
+
)
|
|
65
|
+
return planned + ("\n\n" + extra if extra else "")
|
|
66
|
+
|
|
67
|
+
def _related_context(
|
|
68
|
+
self,
|
|
69
|
+
workspace: Path,
|
|
70
|
+
*,
|
|
71
|
+
query: str,
|
|
72
|
+
seeds: list[str],
|
|
73
|
+
budget: int,
|
|
74
|
+
exclude: set[str],
|
|
75
|
+
) -> str:
|
|
76
|
+
try:
|
|
77
|
+
from forgex.context.manager import ContextManager
|
|
78
|
+
|
|
79
|
+
bundle = ContextManager(workspace).build_context(
|
|
80
|
+
query=query,
|
|
81
|
+
seeds=seeds,
|
|
82
|
+
budget=budget,
|
|
83
|
+
max_files=4,
|
|
84
|
+
exclude=exclude,
|
|
85
|
+
)
|
|
86
|
+
return bundle.render()
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.warning("context retrieval failed; planned files only", exc_info=True)
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
async def execute(
|
|
92
|
+
self,
|
|
93
|
+
task: Task,
|
|
94
|
+
*,
|
|
95
|
+
plan: ArchitecturePlan | None = None,
|
|
96
|
+
research: list[ResearchNote] | None = None,
|
|
97
|
+
) -> BuilderResult:
|
|
98
|
+
if self._events:
|
|
99
|
+
self._events.record(
|
|
100
|
+
EventType.AGENT_STARTED,
|
|
101
|
+
{"agent": self.role.value, "task": task.id},
|
|
102
|
+
run_id=self._run_id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
existing = self._prefetch_existing(task)
|
|
106
|
+
research_text = format_notes(research or [])
|
|
107
|
+
|
|
108
|
+
instruction = (
|
|
109
|
+
f"<task>\nID: {task.id}\nDescription: {task.description}\n"
|
|
110
|
+
f"Files: {', '.join(task.files) or 'none specified'}\n"
|
|
111
|
+
f"Acceptance Criteria: {', '.join(task.acceptance_criteria) or 'none'}\n</task>\n\n"
|
|
112
|
+
f"<plan>\nGoal: {plan.goal if plan else ''}\nArchitecture: {plan.architecture if plan else ''}\n</plan>\n\n"
|
|
113
|
+
f"<research>\n{research_text}\n</research>\n\n"
|
|
114
|
+
f"<existing_files>\n{existing or '(none found)'}\n</existing_files>\n\n"
|
|
115
|
+
"Implement this task using the tools. Inspect files first, make minimal targeted edits, and validate your work before reporting done."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
loop_res = await self._tool_loop(instruction, self._tools, BuilderResult)
|
|
119
|
+
result: BuilderResult = loop_res.report
|
|
120
|
+
|
|
121
|
+
if self._events:
|
|
122
|
+
self._events.record(
|
|
123
|
+
EventType.AGENT_COMPLETED,
|
|
124
|
+
{
|
|
125
|
+
"agent": self.role.value,
|
|
126
|
+
"task": task.id,
|
|
127
|
+
"status": result.status,
|
|
128
|
+
"changed_files": result.changed_files,
|
|
129
|
+
},
|
|
130
|
+
run_id=self._run_id,
|
|
131
|
+
)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_builder(
|
|
136
|
+
*,
|
|
137
|
+
config: ForgexConfig,
|
|
138
|
+
workspace: Path,
|
|
139
|
+
stores: ProjectStores,
|
|
140
|
+
router: ModelRouter | None = None,
|
|
141
|
+
approval: Callable[[str], bool] | None = None,
|
|
142
|
+
run_id: str | None = None,
|
|
143
|
+
) -> BuilderAgent:
|
|
144
|
+
router = router or ModelRouter(config, usage=stores.usage)
|
|
145
|
+
tool_ctx = create_context(
|
|
146
|
+
workspace=workspace,
|
|
147
|
+
config=config,
|
|
148
|
+
events=stores.recorder,
|
|
149
|
+
approval=approval,
|
|
150
|
+
run_id=run_id,
|
|
151
|
+
agent=AgentRole.BUILDER,
|
|
152
|
+
)
|
|
153
|
+
tools = ToolExecutor(
|
|
154
|
+
registry=build_tool_registry(),
|
|
155
|
+
permissions=permission_set_for(AgentRole.BUILDER),
|
|
156
|
+
context=tool_ctx,
|
|
157
|
+
)
|
|
158
|
+
return BuilderAgent(
|
|
159
|
+
router=router,
|
|
160
|
+
tools=tools,
|
|
161
|
+
events=stores.recorder,
|
|
162
|
+
run_id=run_id,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def execute_task(
|
|
167
|
+
task: Task,
|
|
168
|
+
*,
|
|
169
|
+
builder: BuilderAgent,
|
|
170
|
+
stores: ProjectStores,
|
|
171
|
+
config: ForgexConfig,
|
|
172
|
+
plan: ArchitecturePlan | None = None,
|
|
173
|
+
research: list[ResearchNote] | None = None,
|
|
174
|
+
) -> BuilderResult:
|
|
175
|
+
state = stores.state.load()
|
|
176
|
+
if state is None:
|
|
177
|
+
raise ProjectError("Project state missing — run `forgex init` first.")
|
|
178
|
+
|
|
179
|
+
effective_plan = plan or state.architecture_plan
|
|
180
|
+
|
|
181
|
+
tasks = stores.tasks.load()
|
|
182
|
+
task_map = {t.id: t for t in tasks}
|
|
183
|
+
if task.id in task_map:
|
|
184
|
+
task = task_map[task.id]
|
|
185
|
+
|
|
186
|
+
# Verify dependencies
|
|
187
|
+
for dep_id in task.depends_on:
|
|
188
|
+
dep = task_map.get(dep_id)
|
|
189
|
+
if not dep or dep.status not in (TaskStatus.COMPLETED, TaskStatus.SKIPPED):
|
|
190
|
+
raise ProjectError(f"Dependency {dep_id} for {task.id} is not satisfied")
|
|
191
|
+
|
|
192
|
+
# Mark IN_PROGRESS
|
|
193
|
+
task.status = TaskStatus.IN_PROGRESS
|
|
194
|
+
task.attempts += 1
|
|
195
|
+
task.updated_at = datetime.now(UTC)
|
|
196
|
+
state.current_task = task.id
|
|
197
|
+
state.current_agent = AgentRole.BUILDER
|
|
198
|
+
stores.save_tasks(tasks, project_id=state.project_id)
|
|
199
|
+
stores.save_state(state)
|
|
200
|
+
|
|
201
|
+
create_checkpoint(
|
|
202
|
+
f"pre-{task.id}",
|
|
203
|
+
stores=stores,
|
|
204
|
+
description=f"Before executing {task.id}",
|
|
205
|
+
run_id=state.current_run_id,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
stores.recorder.record(
|
|
209
|
+
EventType.TASK_STARTED,
|
|
210
|
+
{"task": task.id, "attempt": task.attempts},
|
|
211
|
+
run_id=state.current_run_id,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
result = await builder.execute(task, plan=effective_plan, research=research)
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
raise
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
task.status = TaskStatus.FAILED
|
|
220
|
+
task.result = f"builder error: {exc}"
|
|
221
|
+
task.updated_at = datetime.now(UTC)
|
|
222
|
+
if task.id not in state.failed_tasks:
|
|
223
|
+
state.failed_tasks.append(task.id)
|
|
224
|
+
state.errors.append(
|
|
225
|
+
ErrorRecord(
|
|
226
|
+
source="builder",
|
|
227
|
+
kind=type(exc).__name__,
|
|
228
|
+
message=str(exc),
|
|
229
|
+
run_id=state.current_run_id,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
state.current_task = None
|
|
233
|
+
state.current_agent = None
|
|
234
|
+
stores.save_tasks(tasks, project_id=state.project_id)
|
|
235
|
+
stores.save_state(state)
|
|
236
|
+
stores.recorder.record(
|
|
237
|
+
EventType.TASK_COMPLETED,
|
|
238
|
+
{"task": task.id, "status": "failed", "error": str(exc)},
|
|
239
|
+
run_id=state.current_run_id,
|
|
240
|
+
)
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
if result.status == "completed":
|
|
244
|
+
task.status = TaskStatus.COMPLETED
|
|
245
|
+
if task.id not in state.completed_tasks:
|
|
246
|
+
state.completed_tasks.append(task.id)
|
|
247
|
+
if task.id in state.failed_tasks:
|
|
248
|
+
state.failed_tasks.remove(task.id)
|
|
249
|
+
else:
|
|
250
|
+
task.status = TaskStatus.FAILED
|
|
251
|
+
if task.id not in state.failed_tasks:
|
|
252
|
+
state.failed_tasks.append(task.id)
|
|
253
|
+
|
|
254
|
+
task.result = result.summary
|
|
255
|
+
task.updated_at = datetime.now(UTC)
|
|
256
|
+
if result.changed_files:
|
|
257
|
+
state.changed_files = sorted(set(state.changed_files) | set(result.changed_files))
|
|
258
|
+
|
|
259
|
+
state.current_task = None
|
|
260
|
+
state.current_agent = None
|
|
261
|
+
stores.save_tasks(tasks, project_id=state.project_id)
|
|
262
|
+
stores.save_state(state)
|
|
263
|
+
|
|
264
|
+
stores.recorder.record(
|
|
265
|
+
EventType.TASK_COMPLETED,
|
|
266
|
+
{"task": task.id, "status": task.status.value, "changed_files": result.changed_files},
|
|
267
|
+
run_id=state.current_run_id,
|
|
268
|
+
)
|
|
269
|
+
return result
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Multi-model ensemble review (spec section 15).
|
|
2
|
+
|
|
3
|
+
N independent reviewer models examine the same diff in parallel, read-only;
|
|
4
|
+
findings are synthesized deterministically (corroboration counting = the judge);
|
|
5
|
+
the senior Expert Coder applies the justified fixes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from forgex.agents.base import BaseAgent, prefetch_files
|
|
16
|
+
from forgex.agents.expert_coder import _single_review, build_coder, resolve_review_scope
|
|
17
|
+
from forgex.config.schema import AgentModelBinding, ForgexConfig
|
|
18
|
+
from forgex.models.router import ModelRouter
|
|
19
|
+
from forgex.orchestration.checkpoints import create_checkpoint
|
|
20
|
+
from forgex.orchestration.stores import ProjectStores
|
|
21
|
+
from forgex.sandbox.permissions import Permission, PermissionSet
|
|
22
|
+
from forgex.schemas.agent import AgentRole, ExpertCoderResult, ReviewFinding, ReviewFindings
|
|
23
|
+
from forgex.schemas.error import ProjectError, ProviderError
|
|
24
|
+
from forgex.schemas.event import EventType
|
|
25
|
+
from forgex.schemas.task import Task
|
|
26
|
+
from forgex.tools import ToolExecutor, build_tool_registry, create_context
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("forgex.ensemble")
|
|
29
|
+
|
|
30
|
+
_WORD_RE = re.compile(r"[a-z0-9_]{3,}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tokenize(text: str) -> set[str]:
|
|
34
|
+
return set(_WORD_RE.findall(text.lower()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
38
|
+
union = a | b
|
|
39
|
+
if not union:
|
|
40
|
+
return 0.0
|
|
41
|
+
return len(a & b) / len(union)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BindingRouter:
|
|
45
|
+
"""Delegates generate() to ModelRouter.generate_with() for a specific binding."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, router: ModelRouter, provider: str, model: str) -> None:
|
|
48
|
+
self._router = router
|
|
49
|
+
self.provider = provider
|
|
50
|
+
self.model = model
|
|
51
|
+
|
|
52
|
+
def capabilities_for(self, agent: str):
|
|
53
|
+
return self._router.capabilities_for_binding(self.provider, self.model)
|
|
54
|
+
|
|
55
|
+
async def generate(self, agent: str, **kwargs) -> Any:
|
|
56
|
+
return await self._router.generate_with(
|
|
57
|
+
provider=self.provider,
|
|
58
|
+
model=self.model,
|
|
59
|
+
agent=agent,
|
|
60
|
+
**kwargs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ReviewerAgent(BaseAgent):
|
|
68
|
+
role = AgentRole.EXPERT_CODER
|
|
69
|
+
prompt_name = "reviewer"
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
router: Any,
|
|
75
|
+
tools: ToolExecutor,
|
|
76
|
+
label: str,
|
|
77
|
+
events=None,
|
|
78
|
+
run_id=None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(router=router, events=events, run_id=run_id)
|
|
81
|
+
self._tools = tools
|
|
82
|
+
self.label = label
|
|
83
|
+
|
|
84
|
+
async def review(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
diff: str,
|
|
88
|
+
changed_files: list[str] | None = None,
|
|
89
|
+
task: Task | None = None,
|
|
90
|
+
plan=None,
|
|
91
|
+
research=None,
|
|
92
|
+
errors=None,
|
|
93
|
+
) -> ReviewFindings:
|
|
94
|
+
existing = prefetch_files(
|
|
95
|
+
self._tools.context.workspace,
|
|
96
|
+
changed_files or [],
|
|
97
|
+
budget=20_000,
|
|
98
|
+
max_files=8,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
parts = [
|
|
102
|
+
f"<diff>\n{diff[:20000]}\n</diff>",
|
|
103
|
+
f"<changed_files>\n{', '.join(changed_files or [])}\n</changed_files>",
|
|
104
|
+
]
|
|
105
|
+
if task:
|
|
106
|
+
parts.append(f"<task>\n{task.id}: {task.description}\n</task>")
|
|
107
|
+
if plan:
|
|
108
|
+
parts.append(f"<plan>\nGoal: {plan.goal}\n</plan>")
|
|
109
|
+
if existing:
|
|
110
|
+
parts.append(f"<existing_files>\n{existing}\n</existing_files>")
|
|
111
|
+
|
|
112
|
+
parts.append(
|
|
113
|
+
"Review the diff above as an independent critic. Report findings with severity and file. Do not edit files."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
loop_res = await self._tool_loop("\n\n".join(parts), self._tools, ReviewFindings)
|
|
117
|
+
return loop_res.report
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def synthesize(
|
|
121
|
+
reviews: list[ReviewFindings],
|
|
122
|
+
) -> tuple[Literal["ok", "needs_changes"], list[ReviewFinding]]:
|
|
123
|
+
"""Synthesize findings across reviewers with corroboration clustering."""
|
|
124
|
+
all_findings: list[tuple[ReviewFinding, int]] = []
|
|
125
|
+
for r_idx, rev in enumerate(reviews):
|
|
126
|
+
for f in rev.findings:
|
|
127
|
+
all_findings.append((f, r_idx))
|
|
128
|
+
|
|
129
|
+
clusters: list[list[tuple[ReviewFinding, int]]] = []
|
|
130
|
+
for f, r_idx in all_findings:
|
|
131
|
+
f_toks = _tokenize(f.issue + " " + (f.suggestion or ""))
|
|
132
|
+
matched = False
|
|
133
|
+
for c in clusters:
|
|
134
|
+
c_toks = _tokenize(c[0][0].issue + " " + (c[0][0].suggestion or ""))
|
|
135
|
+
if _jaccard(f_toks, c_toks) >= 0.4:
|
|
136
|
+
c.append((f, r_idx))
|
|
137
|
+
matched = True
|
|
138
|
+
break
|
|
139
|
+
if not matched:
|
|
140
|
+
clusters.append([(f, r_idx)])
|
|
141
|
+
|
|
142
|
+
confirmed: list[ReviewFinding] = []
|
|
143
|
+
for c in clusters:
|
|
144
|
+
unique_reviewers = {r_idx for _, r_idx in c}
|
|
145
|
+
lead = c[0][0]
|
|
146
|
+
if len(unique_reviewers) >= 2 or lead.severity == "critical" or len(reviews) == 1:
|
|
147
|
+
confirmed.append(lead)
|
|
148
|
+
|
|
149
|
+
needs_changes_count = sum(1 for r in reviews if r.verdict == "needs_changes")
|
|
150
|
+
majority_needs_changes = needs_changes_count > (len(reviews) // 2)
|
|
151
|
+
|
|
152
|
+
has_significant_finding = any(f.severity in ("critical", "major") for f in confirmed)
|
|
153
|
+
|
|
154
|
+
verdict: Literal["ok", "needs_changes"] = (
|
|
155
|
+
"needs_changes" if (has_significant_finding or majority_needs_changes) else "ok"
|
|
156
|
+
)
|
|
157
|
+
return verdict, confirmed
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_reviewer_bindings(config: ForgexConfig) -> list[AgentModelBinding]:
|
|
161
|
+
if config.ensemble.bindings:
|
|
162
|
+
return config.ensemble.bindings[: config.ensemble.reviewers]
|
|
163
|
+
|
|
164
|
+
bindings: list[AgentModelBinding] = []
|
|
165
|
+
ec = config.models.expert_coder
|
|
166
|
+
bindings.append(AgentModelBinding(provider=ec.provider, model=ec.model))
|
|
167
|
+
|
|
168
|
+
if config.fallback.enabled:
|
|
169
|
+
for fb_p in config.fallback.providers:
|
|
170
|
+
if fb_p == ec.provider:
|
|
171
|
+
continue
|
|
172
|
+
prov_cfg = config.providers.get(fb_p)
|
|
173
|
+
if prov_cfg and prov_cfg.default_model:
|
|
174
|
+
bindings.append(AgentModelBinding(provider=fb_p, model=prov_cfg.default_model))
|
|
175
|
+
if len(bindings) >= config.ensemble.reviewers:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
return bindings
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_synthesis(verdict: str, findings: list[ReviewFinding]) -> str:
|
|
182
|
+
lines = [f"Ensemble review synthesis: verdict={verdict}"]
|
|
183
|
+
if findings:
|
|
184
|
+
lines.append("Corroborated findings to address:")
|
|
185
|
+
for f in findings:
|
|
186
|
+
file_part = f" ({f.file})" if f.file else ""
|
|
187
|
+
sugg_part = f" -> Suggestion: {f.suggestion}" if f.suggestion else ""
|
|
188
|
+
lines.append(f"- [{f.severity.upper()}]{file_part} {f.issue}{sugg_part}")
|
|
189
|
+
else:
|
|
190
|
+
lines.append("No corroborated issues found.")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def ensemble_review(
|
|
195
|
+
*,
|
|
196
|
+
stores: ProjectStores,
|
|
197
|
+
config: ForgexConfig,
|
|
198
|
+
task_id: str | None = None,
|
|
199
|
+
base_commit: str | None = None,
|
|
200
|
+
applier: Any | None = None,
|
|
201
|
+
approval: Any = None,
|
|
202
|
+
) -> ExpertCoderResult | None:
|
|
203
|
+
bindings = resolve_reviewer_bindings(config)
|
|
204
|
+
if len(bindings) < 2:
|
|
205
|
+
logger.warning("Fewer than 2 ensemble bindings available; falling back to single review")
|
|
206
|
+
return await _single_review(
|
|
207
|
+
stores=stores,
|
|
208
|
+
config=config,
|
|
209
|
+
task_id=task_id,
|
|
210
|
+
base_commit=base_commit,
|
|
211
|
+
coder=applier,
|
|
212
|
+
approval=approval,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
scope = resolve_review_scope(stores=stores, task_id=task_id, base_commit=base_commit)
|
|
216
|
+
if scope is None:
|
|
217
|
+
return None
|
|
218
|
+
task, diff, changed = scope
|
|
219
|
+
|
|
220
|
+
state = stores.state.load()
|
|
221
|
+
if state is None:
|
|
222
|
+
raise ProjectError("Project state missing")
|
|
223
|
+
|
|
224
|
+
create_checkpoint(
|
|
225
|
+
f"pre-ensemble{'-' + task_id if task_id else ''}",
|
|
226
|
+
stores=stores,
|
|
227
|
+
description="Before ensemble review",
|
|
228
|
+
run_id=state.current_run_id,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
router = ModelRouter(config, usage=stores.usage)
|
|
232
|
+
|
|
233
|
+
async def _run_one(binding: AgentModelBinding, idx: int) -> ReviewFindings:
|
|
234
|
+
label = f"reviewer_{idx}_{binding.provider}"
|
|
235
|
+
b_router = BindingRouter(router, binding.provider, binding.model or "")
|
|
236
|
+
tool_ctx = create_context(
|
|
237
|
+
workspace=stores.root,
|
|
238
|
+
config=config,
|
|
239
|
+
events=stores.recorder,
|
|
240
|
+
run_id=state.current_run_id,
|
|
241
|
+
agent=AgentRole.EXPERT_CODER,
|
|
242
|
+
)
|
|
243
|
+
tools = ToolExecutor(
|
|
244
|
+
registry=build_tool_registry(),
|
|
245
|
+
permissions=PermissionSet(allow={Permission.READ, Permission.GIT, Permission.TEST}),
|
|
246
|
+
context=tool_ctx,
|
|
247
|
+
)
|
|
248
|
+
agent = ReviewerAgent(
|
|
249
|
+
router=b_router,
|
|
250
|
+
tools=tools,
|
|
251
|
+
label=label,
|
|
252
|
+
events=stores.recorder,
|
|
253
|
+
run_id=state.current_run_id,
|
|
254
|
+
)
|
|
255
|
+
return await agent.review(
|
|
256
|
+
diff=diff,
|
|
257
|
+
changed_files=changed,
|
|
258
|
+
task=task,
|
|
259
|
+
plan=state.architecture_plan,
|
|
260
|
+
research=state.research,
|
|
261
|
+
errors=state.errors,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
tasks = [_run_one(b, i) for i, b in enumerate(bindings)]
|
|
265
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
266
|
+
|
|
267
|
+
valid_reviews: list[ReviewFindings] = []
|
|
268
|
+
failures = 0
|
|
269
|
+
for r in results:
|
|
270
|
+
if isinstance(r, BaseException):
|
|
271
|
+
failures += 1
|
|
272
|
+
logger.warning("Ensemble reviewer failed: %s", r)
|
|
273
|
+
else:
|
|
274
|
+
valid_reviews.append(r)
|
|
275
|
+
|
|
276
|
+
if not valid_reviews:
|
|
277
|
+
await router.aclose()
|
|
278
|
+
raise ProviderError("All ensemble reviewers failed")
|
|
279
|
+
|
|
280
|
+
verdict, confirmed_findings = synthesize(valid_reviews)
|
|
281
|
+
|
|
282
|
+
stores.recorder.record(
|
|
283
|
+
EventType.ENSEMBLE_COMPLETED,
|
|
284
|
+
{
|
|
285
|
+
"reviewers": len(bindings),
|
|
286
|
+
"failures": failures,
|
|
287
|
+
"verdict": verdict,
|
|
288
|
+
"confirmed_findings": len(confirmed_findings),
|
|
289
|
+
},
|
|
290
|
+
run_id=state.current_run_id,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if verdict == "ok":
|
|
294
|
+
await router.aclose()
|
|
295
|
+
return ExpertCoderResult(
|
|
296
|
+
status="completed",
|
|
297
|
+
summary=f"[ensemble {len(valid_reviews)}/{len(bindings)}] approved without changes",
|
|
298
|
+
changed_files=[],
|
|
299
|
+
improvements=[],
|
|
300
|
+
issues_found=[f.issue for f in confirmed_findings],
|
|
301
|
+
unfixed_issues=[],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Apply justified fixes
|
|
305
|
+
synth_text = format_synthesis(verdict, confirmed_findings)
|
|
306
|
+
senior = applier or build_coder(
|
|
307
|
+
config=config,
|
|
308
|
+
workspace=stores.root,
|
|
309
|
+
stores=stores,
|
|
310
|
+
router=router,
|
|
311
|
+
approval=approval,
|
|
312
|
+
run_id=state.current_run_id,
|
|
313
|
+
)
|
|
314
|
+
owns_senior = applier is None
|
|
315
|
+
try:
|
|
316
|
+
applied = await senior.review(
|
|
317
|
+
task=task,
|
|
318
|
+
plan=state.architecture_plan,
|
|
319
|
+
research=state.research,
|
|
320
|
+
diff=diff,
|
|
321
|
+
changed_files=changed,
|
|
322
|
+
errors=state.errors,
|
|
323
|
+
extra_context=synth_text,
|
|
324
|
+
)
|
|
325
|
+
finally:
|
|
326
|
+
if owns_senior:
|
|
327
|
+
await senior.aclose()
|
|
328
|
+
await router.aclose()
|
|
329
|
+
|
|
330
|
+
if applied.changed_files:
|
|
331
|
+
state.changed_files = sorted(set(state.changed_files) | set(applied.changed_files))
|
|
332
|
+
stores.save_state(state)
|
|
333
|
+
|
|
334
|
+
return applied
|