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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Expert Coder agent (spec section 10).
|
|
2
|
+
|
|
3
|
+
A senior reviewer that improves the Builder's work: diff-first context,
|
|
4
|
+
minimal targeted changes, verification through tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from forgex.agents.base import BaseAgent, prefetch_files
|
|
14
|
+
from forgex.config.schema import ForgexConfig
|
|
15
|
+
from forgex.models.router import ModelRouter
|
|
16
|
+
from forgex.orchestration.checkpoints import create_checkpoint, find_pre_task_checkpoint
|
|
17
|
+
from forgex.orchestration.events import EventRecorder
|
|
18
|
+
from forgex.orchestration.stores import ProjectStores
|
|
19
|
+
from forgex.research.citations import format_notes
|
|
20
|
+
from forgex.sandbox.permissions import permission_set_for
|
|
21
|
+
from forgex.schemas.agent import AgentRole, ExpertCoderResult
|
|
22
|
+
from forgex.schemas.error import ErrorRecord, ForgexError, ProjectError
|
|
23
|
+
from forgex.schemas.event import EventType
|
|
24
|
+
from forgex.schemas.plan import ArchitecturePlan
|
|
25
|
+
from forgex.schemas.research import ResearchNote
|
|
26
|
+
from forgex.schemas.task import Task
|
|
27
|
+
from forgex.tools import ToolExecutor, build_tool_registry, create_context
|
|
28
|
+
from forgex.tools.git import git_changed_files, git_diff, git_diff_range, is_dirty
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("forgex.agents.expert_coder")
|
|
31
|
+
|
|
32
|
+
MAX_DIFF_CHARS = 20_000
|
|
33
|
+
MAX_PREFETCH_CHARS = 40_000
|
|
34
|
+
MAX_PREFETCH_FILES = 12
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExpertCoderAgent(BaseAgent):
|
|
38
|
+
role = AgentRole.EXPERT_CODER
|
|
39
|
+
prompt_name = "expert_coder"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
router: ModelRouter,
|
|
45
|
+
tools: ToolExecutor,
|
|
46
|
+
events: EventRecorder | None = None,
|
|
47
|
+
run_id: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(router=router, events=events, run_id=run_id)
|
|
50
|
+
self._tools = tools
|
|
51
|
+
|
|
52
|
+
async def review(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
task: Task | None = None,
|
|
56
|
+
plan: ArchitecturePlan | None = None,
|
|
57
|
+
research: list[ResearchNote] | None = None,
|
|
58
|
+
diff: str = "",
|
|
59
|
+
changed_files: list[str] | None = None,
|
|
60
|
+
errors: list[ErrorRecord] | None = None,
|
|
61
|
+
extra_context: str = "",
|
|
62
|
+
) -> ExpertCoderResult:
|
|
63
|
+
if self._events:
|
|
64
|
+
self._events.record(
|
|
65
|
+
EventType.AGENT_STARTED,
|
|
66
|
+
{"agent": self.role.value, "task": task.id if task else None},
|
|
67
|
+
run_id=self._run_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
changed = list(changed_files or [])
|
|
71
|
+
seeds = list(changed)
|
|
72
|
+
try:
|
|
73
|
+
from forgex.context.manager import ContextManager
|
|
74
|
+
|
|
75
|
+
manager = ContextManager(self._tools.context.workspace)
|
|
76
|
+
for s in changed:
|
|
77
|
+
for imp in manager.importers_of(s, max_depth=1):
|
|
78
|
+
if imp not in seeds:
|
|
79
|
+
seeds.append(imp)
|
|
80
|
+
seeds = seeds[:MAX_PREFETCH_FILES]
|
|
81
|
+
except Exception:
|
|
82
|
+
logger.warning("impact analysis unavailable; changed files only", exc_info=True)
|
|
83
|
+
|
|
84
|
+
existing = prefetch_files(
|
|
85
|
+
self._tools.context.workspace,
|
|
86
|
+
seeds,
|
|
87
|
+
budget=MAX_PREFETCH_CHARS,
|
|
88
|
+
max_files=MAX_PREFETCH_FILES,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parts: list[str] = []
|
|
92
|
+
if task:
|
|
93
|
+
parts.append(
|
|
94
|
+
f"<task>\nID: {task.id}\nDescription: {task.description}\nCriteria: {', '.join(task.acceptance_criteria)}\n</task>"
|
|
95
|
+
)
|
|
96
|
+
if plan:
|
|
97
|
+
parts.append(f"<plan>\nGoal: {plan.goal}\nArchitecture: {plan.architecture}\n</plan>")
|
|
98
|
+
if research:
|
|
99
|
+
parts.append(f"<research>\n{format_notes(research)}\n</research>")
|
|
100
|
+
if errors:
|
|
101
|
+
err_text = "\n".join(f"- [{e.source}] {e.kind}: {e.message}" for e in errors[-5:])
|
|
102
|
+
parts.append(f"<errors>\n{err_text}\n</errors>")
|
|
103
|
+
if extra_context:
|
|
104
|
+
parts.append(extra_context)
|
|
105
|
+
|
|
106
|
+
diff_snippet = diff[:MAX_DIFF_CHARS] + ("\n... [diff trimmed]" if len(diff) > MAX_DIFF_CHARS else "")
|
|
107
|
+
parts.append(f"<diff>\n{diff_snippet or '(no diff)'}\n</diff>")
|
|
108
|
+
if existing:
|
|
109
|
+
parts.append(f"<existing_files>\n{existing}\n</existing_files>")
|
|
110
|
+
|
|
111
|
+
parts.append(
|
|
112
|
+
"Review the changes above. Refactor, optimize, fix, simplify, and verify with tests. If the code is already solid, change nothing."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
instruction = "\n\n".join(parts)
|
|
116
|
+
loop_res = await self._tool_loop(instruction, self._tools, ExpertCoderResult)
|
|
117
|
+
result: ExpertCoderResult = loop_res.report
|
|
118
|
+
|
|
119
|
+
if self._events:
|
|
120
|
+
self._events.record(
|
|
121
|
+
EventType.AGENT_COMPLETED,
|
|
122
|
+
{
|
|
123
|
+
"agent": self.role.value,
|
|
124
|
+
"status": result.status,
|
|
125
|
+
"improvements": len(result.improvements),
|
|
126
|
+
"changed_files": result.changed_files,
|
|
127
|
+
},
|
|
128
|
+
run_id=self._run_id,
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_coder(
|
|
134
|
+
*,
|
|
135
|
+
config: ForgexConfig,
|
|
136
|
+
workspace: Path,
|
|
137
|
+
stores: ProjectStores,
|
|
138
|
+
router: ModelRouter | None = None,
|
|
139
|
+
approval: Callable[[str], bool] | None = None,
|
|
140
|
+
run_id: str | None = None,
|
|
141
|
+
) -> ExpertCoderAgent:
|
|
142
|
+
router = router or ModelRouter(config, usage=stores.usage)
|
|
143
|
+
tool_ctx = create_context(
|
|
144
|
+
workspace=workspace,
|
|
145
|
+
config=config,
|
|
146
|
+
events=stores.recorder,
|
|
147
|
+
approval=approval,
|
|
148
|
+
run_id=run_id,
|
|
149
|
+
agent=AgentRole.EXPERT_CODER,
|
|
150
|
+
)
|
|
151
|
+
tools = ToolExecutor(
|
|
152
|
+
registry=build_tool_registry(),
|
|
153
|
+
permissions=permission_set_for(AgentRole.EXPERT_CODER),
|
|
154
|
+
context=tool_ctx,
|
|
155
|
+
)
|
|
156
|
+
return ExpertCoderAgent(
|
|
157
|
+
router=router,
|
|
158
|
+
tools=tools,
|
|
159
|
+
events=stores.recorder,
|
|
160
|
+
run_id=run_id,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_review_scope(
|
|
165
|
+
*,
|
|
166
|
+
stores: ProjectStores,
|
|
167
|
+
task_id: str | None,
|
|
168
|
+
base_commit: str | None,
|
|
169
|
+
) -> tuple[Task | None, str, list[str]] | None:
|
|
170
|
+
task = None
|
|
171
|
+
if task_id is not None:
|
|
172
|
+
tasks = stores.tasks.load()
|
|
173
|
+
task = next((t for t in tasks if t.id == task_id), None)
|
|
174
|
+
if task is None:
|
|
175
|
+
raise ProjectError(f"Unknown task: {task_id}")
|
|
176
|
+
|
|
177
|
+
if base_commit is None and task_id is not None and not is_dirty(stores.root):
|
|
178
|
+
record = find_pre_task_checkpoint(task_id, stores=stores)
|
|
179
|
+
if record is not None and record.git_commit:
|
|
180
|
+
base_commit = record.git_commit
|
|
181
|
+
|
|
182
|
+
if base_commit:
|
|
183
|
+
diff = git_diff_range(stores.root, base_commit)
|
|
184
|
+
changed = git_changed_files(stores.root, base_commit)
|
|
185
|
+
else:
|
|
186
|
+
diff = git_diff(stores.root)
|
|
187
|
+
changed = git_changed_files(stores.root)
|
|
188
|
+
|
|
189
|
+
if not diff and not changed:
|
|
190
|
+
return None
|
|
191
|
+
return task, diff, changed
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _single_review(
|
|
195
|
+
*,
|
|
196
|
+
stores: ProjectStores,
|
|
197
|
+
config: ForgexConfig,
|
|
198
|
+
task_id: str | None = None,
|
|
199
|
+
base_commit: str | None = None,
|
|
200
|
+
coder: ExpertCoderAgent | None = None,
|
|
201
|
+
approval: Callable[[str], bool] | None = None,
|
|
202
|
+
) -> ExpertCoderResult | None:
|
|
203
|
+
state = stores.state.load()
|
|
204
|
+
if state is None:
|
|
205
|
+
raise ProjectError("Project state missing — run `forgex init` first.")
|
|
206
|
+
|
|
207
|
+
scope = resolve_review_scope(stores=stores, task_id=task_id, base_commit=base_commit)
|
|
208
|
+
if scope is None:
|
|
209
|
+
return None
|
|
210
|
+
task, diff, changed = scope
|
|
211
|
+
|
|
212
|
+
create_checkpoint(
|
|
213
|
+
f"pre-review{'-' + task_id if task_id else ''}",
|
|
214
|
+
stores=stores,
|
|
215
|
+
description="Before expert coder review",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
agent = coder or build_coder(
|
|
219
|
+
config=config,
|
|
220
|
+
workspace=stores.root,
|
|
221
|
+
stores=stores,
|
|
222
|
+
approval=approval,
|
|
223
|
+
run_id=state.current_run_id,
|
|
224
|
+
)
|
|
225
|
+
owns_agent = coder is None
|
|
226
|
+
try:
|
|
227
|
+
result = await agent.review(
|
|
228
|
+
task=task,
|
|
229
|
+
plan=state.architecture_plan,
|
|
230
|
+
research=state.research,
|
|
231
|
+
diff=diff,
|
|
232
|
+
changed_files=changed,
|
|
233
|
+
errors=state.errors[-5:],
|
|
234
|
+
)
|
|
235
|
+
except ForgexError as exc:
|
|
236
|
+
state.errors.append(
|
|
237
|
+
ErrorRecord(
|
|
238
|
+
source="expert_coder",
|
|
239
|
+
kind=type(exc).__name__,
|
|
240
|
+
message=str(exc),
|
|
241
|
+
run_id=state.current_run_id,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
stores.save_state(state)
|
|
245
|
+
raise
|
|
246
|
+
finally:
|
|
247
|
+
if owns_agent:
|
|
248
|
+
await agent.aclose()
|
|
249
|
+
|
|
250
|
+
if result.changed_files:
|
|
251
|
+
state.changed_files = sorted(set(state.changed_files) | set(result.changed_files))
|
|
252
|
+
stores.save_state(state)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def review_work(
|
|
257
|
+
*,
|
|
258
|
+
stores: ProjectStores,
|
|
259
|
+
config: ForgexConfig,
|
|
260
|
+
task_id: str | None = None,
|
|
261
|
+
base_commit: str | None = None,
|
|
262
|
+
coder: ExpertCoderAgent | None = None,
|
|
263
|
+
approval: Callable[[str], bool] | None = None,
|
|
264
|
+
) -> ExpertCoderResult | None:
|
|
265
|
+
if config.ensemble.enabled:
|
|
266
|
+
from forgex.agents.ensemble import ensemble_review
|
|
267
|
+
|
|
268
|
+
return await ensemble_review(
|
|
269
|
+
stores=stores,
|
|
270
|
+
config=config,
|
|
271
|
+
task_id=task_id,
|
|
272
|
+
base_commit=base_commit,
|
|
273
|
+
applier=coder,
|
|
274
|
+
approval=approval,
|
|
275
|
+
)
|
|
276
|
+
return await _single_review(
|
|
277
|
+
stores=stores,
|
|
278
|
+
config=config,
|
|
279
|
+
task_id=task_id,
|
|
280
|
+
base_commit=base_commit,
|
|
281
|
+
coder=coder,
|
|
282
|
+
approval=approval,
|
|
283
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Supervisor agent (spec sections 11, 56, 58).
|
|
2
|
+
|
|
3
|
+
inspect → diagnose → instruct repair.
|
|
4
|
+
Verification is deterministic; this agent adds audit judgment;
|
|
5
|
+
quality gates enforce that red tests/builds never pass.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from forgex.agents.base import BaseAgent
|
|
15
|
+
from forgex.config.schema import ForgexConfig
|
|
16
|
+
from forgex.models.router import ModelRouter
|
|
17
|
+
from forgex.orchestration.events import EventRecorder
|
|
18
|
+
from forgex.orchestration.stores import ProjectStores
|
|
19
|
+
from forgex.research.citations import format_notes
|
|
20
|
+
from forgex.sandbox.executor import make_executor
|
|
21
|
+
from forgex.sandbox.permissions import permission_set_for
|
|
22
|
+
from forgex.schemas.agent import (
|
|
23
|
+
AgentRole,
|
|
24
|
+
SupervisorJudgment,
|
|
25
|
+
SupervisorReport,
|
|
26
|
+
)
|
|
27
|
+
from forgex.schemas.error import ErrorRecord, ProjectError
|
|
28
|
+
from forgex.schemas.event import EventType
|
|
29
|
+
from forgex.schemas.plan import ArchitecturePlan
|
|
30
|
+
from forgex.schemas.research import ResearchNote
|
|
31
|
+
from forgex.schemas.result import VerificationOutcome
|
|
32
|
+
from forgex.schemas.task import Task
|
|
33
|
+
from forgex.tools import ToolExecutor, build_tool_registry, create_context
|
|
34
|
+
from forgex.tools.git import current_branch, git_log, has_git_repo, is_dirty
|
|
35
|
+
from forgex.tools.verification import run_verification
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("forgex.agents.supervisor")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git_summary(root: Path) -> str:
|
|
41
|
+
if not has_git_repo(root):
|
|
42
|
+
return "not a git repository"
|
|
43
|
+
branch = current_branch(root) or "unknown"
|
|
44
|
+
dirty = "dirty" if is_dirty(root) else "clean"
|
|
45
|
+
log = git_log(root, limit=5)
|
|
46
|
+
return f"Branch: {branch} ({dirty})\nRecent commits:\n{log}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_verification(outcome: VerificationOutcome) -> str:
|
|
50
|
+
lines = []
|
|
51
|
+
if outcome.tests:
|
|
52
|
+
t = outcome.tests
|
|
53
|
+
p = t.passed if t.passed is not None else "?"
|
|
54
|
+
f = t.failed if t.failed is not None else "?"
|
|
55
|
+
lines.append(f"Tests: exit={t.exit_code}, passed={p}, failed={f}, timed_out={t.timed_out}")
|
|
56
|
+
if t.output_tail:
|
|
57
|
+
lines.append(f"--- test tail ---\n{t.output_tail[-1500:]}")
|
|
58
|
+
else:
|
|
59
|
+
lines.append("Tests: not detected / not run")
|
|
60
|
+
|
|
61
|
+
if outcome.build:
|
|
62
|
+
b = outcome.build
|
|
63
|
+
lines.append(f"Build: exit={b.exit_code}, timed_out={b.timed_out}")
|
|
64
|
+
if not b.ok and b.output_tail:
|
|
65
|
+
lines.append(f"--- build error ---\n{b.output_tail[-1000:]}")
|
|
66
|
+
|
|
67
|
+
if outcome.lint:
|
|
68
|
+
ln = outcome.lint
|
|
69
|
+
lines.append(f"Lint: exit={ln.exit_code}")
|
|
70
|
+
if not ln.ok and ln.output_tail:
|
|
71
|
+
lines.append(f"--- lint error ---\n{ln.output_tail[-1000:]}")
|
|
72
|
+
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apply_quality_gates(
|
|
77
|
+
judgment: SupervisorJudgment, outcome: VerificationOutcome
|
|
78
|
+
) -> SupervisorReport:
|
|
79
|
+
"""Compose final report. Gates can only worsen a verdict, never improve it."""
|
|
80
|
+
status = judgment.status
|
|
81
|
+
errors = list(judgment.errors)
|
|
82
|
+
warnings = list(judgment.warnings)
|
|
83
|
+
req_changes = list(judgment.required_changes)
|
|
84
|
+
|
|
85
|
+
# 1. Hard gates
|
|
86
|
+
if outcome.tests is not None:
|
|
87
|
+
if (outcome.tests.exit_code is not None and outcome.tests.exit_code not in (0, 5)) or (
|
|
88
|
+
outcome.tests.failed and outcome.tests.failed > 0
|
|
89
|
+
):
|
|
90
|
+
status = "fail"
|
|
91
|
+
msg = f"tests failed ({outcome.tests.failed or 0} failed, exit {outcome.tests.exit_code})"
|
|
92
|
+
if msg not in errors:
|
|
93
|
+
errors.append(msg)
|
|
94
|
+
if not req_changes:
|
|
95
|
+
req_changes.append("Fix the failing test suite")
|
|
96
|
+
if outcome.tests.timed_out:
|
|
97
|
+
status = "fail"
|
|
98
|
+
if "tests timed out" not in errors:
|
|
99
|
+
errors.append("tests timed out")
|
|
100
|
+
|
|
101
|
+
if outcome.build is not None and not outcome.build.ok:
|
|
102
|
+
status = "fail"
|
|
103
|
+
msg = f"build failed (exit {outcome.build.exit_code})"
|
|
104
|
+
if msg not in errors:
|
|
105
|
+
errors.append(msg)
|
|
106
|
+
if not req_changes:
|
|
107
|
+
req_changes.append("Fix build failures")
|
|
108
|
+
|
|
109
|
+
if outcome.lint is not None and not outcome.lint.ok:
|
|
110
|
+
status = "fail"
|
|
111
|
+
msg = f"lint failed (exit {outcome.lint.exit_code})"
|
|
112
|
+
if msg not in errors:
|
|
113
|
+
errors.append(msg)
|
|
114
|
+
|
|
115
|
+
# 2. Soft gates (floor at needs_review)
|
|
116
|
+
if outcome.tests is None:
|
|
117
|
+
warnings.append("no test suite detected")
|
|
118
|
+
if status == "pass":
|
|
119
|
+
status = "needs_review"
|
|
120
|
+
elif outcome.tests.exit_code == 5:
|
|
121
|
+
warnings.append("pytest collected 0 tests")
|
|
122
|
+
if status == "pass":
|
|
123
|
+
status = "needs_review"
|
|
124
|
+
elif outcome.tests.exit_code is None:
|
|
125
|
+
warnings.append("test command was not executed")
|
|
126
|
+
if not any("tests failed" in e for e in errors):
|
|
127
|
+
errors.append("tests failed: command denied")
|
|
128
|
+
status = "needs_review"
|
|
129
|
+
|
|
130
|
+
# 3. Model verdict preservation
|
|
131
|
+
if judgment.status in ("fail", "needs_review"):
|
|
132
|
+
status = judgment.status
|
|
133
|
+
|
|
134
|
+
return SupervisorReport(
|
|
135
|
+
status=status,
|
|
136
|
+
summary=judgment.summary,
|
|
137
|
+
errors=errors,
|
|
138
|
+
warnings=warnings,
|
|
139
|
+
required_changes=req_changes,
|
|
140
|
+
recommended_changes=judgment.recommended_changes,
|
|
141
|
+
tests=outcome.tests,
|
|
142
|
+
build=outcome.build,
|
|
143
|
+
lint=outcome.lint,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class SupervisorAgent(BaseAgent):
|
|
148
|
+
role = AgentRole.SUPERVISOR
|
|
149
|
+
prompt_name = "supervisor"
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
router: ModelRouter,
|
|
155
|
+
tools: ToolExecutor,
|
|
156
|
+
events: EventRecorder | None = None,
|
|
157
|
+
run_id: str | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
super().__init__(router=router, events=events, run_id=run_id)
|
|
160
|
+
self._tools = tools
|
|
161
|
+
|
|
162
|
+
async def audit(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
request: str,
|
|
166
|
+
plan: ArchitecturePlan | None,
|
|
167
|
+
tasks: list[Task],
|
|
168
|
+
verification: VerificationOutcome,
|
|
169
|
+
research: list[ResearchNote] | None = None,
|
|
170
|
+
errors: list[ErrorRecord] | None = None,
|
|
171
|
+
) -> SupervisorJudgment:
|
|
172
|
+
if self._events:
|
|
173
|
+
self._events.record(
|
|
174
|
+
EventType.AGENT_STARTED,
|
|
175
|
+
{"agent": self.role.value},
|
|
176
|
+
run_id=self._run_id,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
v_text = _format_verification(verification)
|
|
180
|
+
git_text = _git_summary(self._tools.context.workspace)
|
|
181
|
+
research_text = format_notes(research or [])
|
|
182
|
+
|
|
183
|
+
tasks_summary = "\n".join(
|
|
184
|
+
f"- [{t.status.value}] {t.id}: {t.description} (attempts: {t.attempts})"
|
|
185
|
+
for t in tasks
|
|
186
|
+
)
|
|
187
|
+
err_text = "\n".join(f"- [{e.source}] {e.kind}: {e.message}" for e in (errors or [])[-5:])
|
|
188
|
+
|
|
189
|
+
instruction = (
|
|
190
|
+
f"<user_request>\n{request}\n</user_request>\n\n"
|
|
191
|
+
f"<plan>\nGoal: {plan.goal if plan else 'none'}\nCriteria: {', '.join(plan.acceptance_criteria) if plan else 'none'}\n</plan>\n\n"
|
|
192
|
+
f"<tasks>\n{tasks_summary}\n</tasks>\n\n"
|
|
193
|
+
f"<verification>\n{v_text}\n</verification>\n\n"
|
|
194
|
+
f"<git>\n{git_text}\n</git>\n\n"
|
|
195
|
+
f"<research>\n{research_text}\n</research>\n\n"
|
|
196
|
+
f"<errors>\n{err_text or '(no errors recorded)'}\n</errors>\n\n"
|
|
197
|
+
"Audit the repository. Use tools to inspect code if needed. Return your decisive verdict (pass/fail/needs_review)."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
loop_res = await self._tool_loop(instruction, self._tools, SupervisorJudgment)
|
|
201
|
+
result: SupervisorJudgment = loop_res.report
|
|
202
|
+
|
|
203
|
+
if self._events:
|
|
204
|
+
self._events.record(
|
|
205
|
+
EventType.AGENT_COMPLETED,
|
|
206
|
+
{
|
|
207
|
+
"agent": self.role.value,
|
|
208
|
+
"status": result.status,
|
|
209
|
+
"errors": len(result.errors),
|
|
210
|
+
"required_changes": len(result.required_changes),
|
|
211
|
+
},
|
|
212
|
+
run_id=self._run_id,
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_supervisor(
|
|
218
|
+
*,
|
|
219
|
+
config: ForgexConfig,
|
|
220
|
+
workspace: Path,
|
|
221
|
+
stores: ProjectStores,
|
|
222
|
+
router: ModelRouter | None = None,
|
|
223
|
+
approval: Callable[[str], bool] | None = None,
|
|
224
|
+
run_id: str | None = None,
|
|
225
|
+
) -> SupervisorAgent:
|
|
226
|
+
router = router or ModelRouter(config, usage=stores.usage)
|
|
227
|
+
tool_ctx = create_context(
|
|
228
|
+
workspace=workspace,
|
|
229
|
+
config=config,
|
|
230
|
+
events=stores.recorder,
|
|
231
|
+
approval=approval,
|
|
232
|
+
run_id=run_id,
|
|
233
|
+
agent=AgentRole.SUPERVISOR,
|
|
234
|
+
)
|
|
235
|
+
tools = ToolExecutor(
|
|
236
|
+
registry=build_tool_registry(),
|
|
237
|
+
permissions=permission_set_for(AgentRole.SUPERVISOR),
|
|
238
|
+
context=tool_ctx,
|
|
239
|
+
)
|
|
240
|
+
return SupervisorAgent(
|
|
241
|
+
router=router,
|
|
242
|
+
tools=tools,
|
|
243
|
+
events=stores.recorder,
|
|
244
|
+
run_id=run_id,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def supervise(
|
|
249
|
+
*,
|
|
250
|
+
stores: ProjectStores,
|
|
251
|
+
config: ForgexConfig,
|
|
252
|
+
approval: Callable[[str], bool] | None = None,
|
|
253
|
+
include_build: bool = True,
|
|
254
|
+
include_lint: bool = True,
|
|
255
|
+
supervisor: SupervisorAgent | None = None,
|
|
256
|
+
) -> SupervisorReport:
|
|
257
|
+
state = stores.state.load()
|
|
258
|
+
if state is None:
|
|
259
|
+
raise ProjectError("Project state missing — run `forgex init` first.")
|
|
260
|
+
|
|
261
|
+
agent = supervisor or build_supervisor(
|
|
262
|
+
config=config,
|
|
263
|
+
workspace=stores.root,
|
|
264
|
+
stores=stores,
|
|
265
|
+
approval=approval,
|
|
266
|
+
run_id=state.current_run_id,
|
|
267
|
+
)
|
|
268
|
+
owns_agent = supervisor is None
|
|
269
|
+
|
|
270
|
+
exec_inst = getattr(getattr(getattr(agent, "_tools", None), "context", None), "executor", None)
|
|
271
|
+
if exec_inst is None:
|
|
272
|
+
exec_inst = make_executor(stores.root, approval=approval)
|
|
273
|
+
|
|
274
|
+
outcome = await run_verification(
|
|
275
|
+
root=stores.root,
|
|
276
|
+
executor=exec_inst,
|
|
277
|
+
events=stores.recorder,
|
|
278
|
+
run_id=state.current_run_id,
|
|
279
|
+
include_build=include_build,
|
|
280
|
+
include_lint=include_lint,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
judgment = await agent.audit(
|
|
285
|
+
request=state.user_request or "",
|
|
286
|
+
plan=state.architecture_plan,
|
|
287
|
+
tasks=stores.tasks.load(),
|
|
288
|
+
verification=outcome,
|
|
289
|
+
research=state.research,
|
|
290
|
+
errors=state.errors,
|
|
291
|
+
)
|
|
292
|
+
finally:
|
|
293
|
+
if owns_agent:
|
|
294
|
+
await agent.aclose()
|
|
295
|
+
|
|
296
|
+
report = apply_quality_gates(judgment, outcome)
|
|
297
|
+
|
|
298
|
+
state.test_results = outcome.tests
|
|
299
|
+
state.build_results = outcome.build
|
|
300
|
+
state.last_supervisor_status = report.status
|
|
301
|
+
stores.save_state(state)
|
|
302
|
+
|
|
303
|
+
return report
|
forgex/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package."""
|