forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""High-level runner that wraps the ExecutionEngine for CLI use.
|
|
2
|
+
|
|
3
|
+
Provides :func:`run_engine` which accepts the same high-level
|
|
4
|
+
parameters as the CLI (prompt, provider, optimizer, graph, etc.)
|
|
5
|
+
and returns an :class:`EngineResult` ready for rendering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from forgecli.engine.context import EngineContext
|
|
14
|
+
from forgecli.engine.defaults import default_registry
|
|
15
|
+
from forgecli.engine.execution import EngineResult, ExecutionEngine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_engine(
|
|
19
|
+
prompt: str,
|
|
20
|
+
root: Path,
|
|
21
|
+
*,
|
|
22
|
+
provider: Any = None,
|
|
23
|
+
optimizer: Any = None,
|
|
24
|
+
caveman_optimizer: Any = None,
|
|
25
|
+
graph: Any = None,
|
|
26
|
+
classifier: Any = None,
|
|
27
|
+
router: Any = None,
|
|
28
|
+
test_command: str | None = None,
|
|
29
|
+
test_timeout: float = 120.0,
|
|
30
|
+
retries: int = 0,
|
|
31
|
+
skip_tests: bool = False,
|
|
32
|
+
skip_graph: bool = False,
|
|
33
|
+
skip_ponytail: bool = False,
|
|
34
|
+
skip_caveman: bool = False,
|
|
35
|
+
pipeline_names: tuple[str, ...] | None = None,
|
|
36
|
+
extras: dict[str, Any] | None = None,
|
|
37
|
+
) -> EngineResult:
|
|
38
|
+
"""Build an :class:`ExecutionEngine`, run the pipeline, and return the result.
|
|
39
|
+
|
|
40
|
+
Parameters mirror the CLI flags; keyword arguments not consumed by
|
|
41
|
+
this function are forwarded to :func:`default_registry`.
|
|
42
|
+
|
|
43
|
+
Returns an :class:`EngineResult` that callers can render as text
|
|
44
|
+
or JSON.
|
|
45
|
+
"""
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
engine_ctx = EngineContext(
|
|
49
|
+
prompt=prompt,
|
|
50
|
+
cwd=root,
|
|
51
|
+
)
|
|
52
|
+
if extras:
|
|
53
|
+
engine_ctx.extras.update(extras)
|
|
54
|
+
|
|
55
|
+
if not skip_caveman and caveman_optimizer is not None:
|
|
56
|
+
engine_ctx.extras["caveman_optimizer"] = caveman_optimizer
|
|
57
|
+
if not skip_graph and graph is not None:
|
|
58
|
+
engine_ctx.extras["graph"] = graph
|
|
59
|
+
if not skip_ponytail and optimizer is not None:
|
|
60
|
+
engine_ctx.extras["optimizer"] = optimizer
|
|
61
|
+
if provider is not None:
|
|
62
|
+
engine_ctx.extras["provider"] = provider
|
|
63
|
+
if retries:
|
|
64
|
+
engine_ctx.extras["retries"] = retries
|
|
65
|
+
if test_command is not None and not skip_tests:
|
|
66
|
+
engine_ctx.extras["test_command"] = test_command
|
|
67
|
+
engine_ctx.extras["test_timeout"] = test_timeout
|
|
68
|
+
|
|
69
|
+
registry = default_registry(
|
|
70
|
+
provider=provider,
|
|
71
|
+
optimizer=optimizer if not skip_ponytail else None,
|
|
72
|
+
caveman_optimizer=caveman_optimizer if not skip_caveman else None,
|
|
73
|
+
graph=graph if not skip_graph else None,
|
|
74
|
+
classifier=classifier,
|
|
75
|
+
router=router,
|
|
76
|
+
test_command=None if skip_tests else test_command,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
names = pipeline_names or ExecutionEngine.DEFAULT_PIPELINE
|
|
80
|
+
engine = ExecutionEngine.from_registry(registry, names=names)
|
|
81
|
+
|
|
82
|
+
return asyncio.run(engine.run(engine_ctx))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def engine_result_to_dict(result: EngineResult) -> dict[str, object]:
|
|
86
|
+
"""Return a JSON-serializable view of an :class:`EngineResult`."""
|
|
87
|
+
ctx = result.context
|
|
88
|
+
return {
|
|
89
|
+
"success": result.success,
|
|
90
|
+
"cancelled": result.cancelled,
|
|
91
|
+
"failed_stage": result.failed_stage,
|
|
92
|
+
"error": result.error,
|
|
93
|
+
"prompt": ctx.prompt,
|
|
94
|
+
"run_id": ctx.run_id,
|
|
95
|
+
"intent": ctx.intent_analysis.intent.value if ctx.intent_analysis else None,
|
|
96
|
+
"intent_confidence": ctx.intent_analysis.confidence if ctx.intent_analysis else 0.0,
|
|
97
|
+
"retrieval_match_count": len(ctx.retrieval.matched_nodes) if ctx.retrieval else 0,
|
|
98
|
+
"model": ctx.model_selection.model if ctx.model_selection else None,
|
|
99
|
+
"provider": ctx.model_selection.provider if ctx.model_selection else None,
|
|
100
|
+
"diff_length": len(ctx.diff_text),
|
|
101
|
+
"applied_files": [str(p) for p in ctx.applied_files],
|
|
102
|
+
"test_returncode": ctx.test_returncode,
|
|
103
|
+
"fix_attempts": ctx.fix_attempts,
|
|
104
|
+
"stages": [
|
|
105
|
+
{
|
|
106
|
+
"name": log.stage,
|
|
107
|
+
"status": log.status,
|
|
108
|
+
"duration_seconds": log.duration_seconds,
|
|
109
|
+
"error": log.error,
|
|
110
|
+
}
|
|
111
|
+
for log in ctx.log
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_engine_result(result: EngineResult) -> str:
|
|
117
|
+
"""Return a human-readable summary of the engine run.
|
|
118
|
+
|
|
119
|
+
Format is similar to the build pipeline's ``build_summary`` but
|
|
120
|
+
reads from :class:`EngineContext` instead of :class:`BuildContext`.
|
|
121
|
+
"""
|
|
122
|
+
ctx = result.context
|
|
123
|
+
lines: list[str] = []
|
|
124
|
+
lines.append(f"Goal: {ctx.prompt}")
|
|
125
|
+
if ctx.model_selection is not None:
|
|
126
|
+
ms = ctx.model_selection
|
|
127
|
+
lines.append(
|
|
128
|
+
f"Route: {ms.provider}/{ms.model} (mode={ms.mode}, "
|
|
129
|
+
f"in=${ms.cost_in:.5f}/1k, out=${ms.cost_out:.5f}/1k)"
|
|
130
|
+
)
|
|
131
|
+
if ctx.applied_files:
|
|
132
|
+
rel = [str(p.relative_to(ctx.cwd) if p.is_relative_to(ctx.cwd) else p) for p in ctx.applied_files]
|
|
133
|
+
lines.append(f"Files touched ({len(rel)}):")
|
|
134
|
+
for path in rel:
|
|
135
|
+
lines.append(f" - {path}")
|
|
136
|
+
else:
|
|
137
|
+
lines.append("Files touched: (none)")
|
|
138
|
+
if ctx.test_returncode is None:
|
|
139
|
+
lines.append("Tests: skipped")
|
|
140
|
+
elif ctx.test_returncode == 0:
|
|
141
|
+
lines.append("Tests: passed")
|
|
142
|
+
else:
|
|
143
|
+
lines.append(f"Tests: FAILED (exit {ctx.test_returncode})")
|
|
144
|
+
total = sum(log.duration_seconds for log in ctx.log)
|
|
145
|
+
lines.append(f"Total time: {total:.2f}s across {len(ctx.log)} stages")
|
|
146
|
+
if result.error:
|
|
147
|
+
lines.append(f"Error: {result.error}")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"engine_result_to_dict",
|
|
153
|
+
"render_engine_result",
|
|
154
|
+
"run_engine",
|
|
155
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Concrete Stage implementations for the Execution Engine.
|
|
2
|
+
|
|
3
|
+
Each module in this package implements one or more :class:`Stage`
|
|
4
|
+
protocol objects that wire into the engine's
|
|
5
|
+
:attr:`~forgecli.engine.execution.ExecutionEngine.DEFAULT_PIPELINE`.
|
|
6
|
+
|
|
7
|
+
Stages are thin adapters over the existing build pipeline functions
|
|
8
|
+
in :mod:`forgecli.build` — they map between :class:`EngineContext`
|
|
9
|
+
and :class:`BuildContext`, then delegate to the well-tested build
|
|
10
|
+
functions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from forgecli.engine.stages.caveman_optimizer import CavemanOptimizerStage
|
|
14
|
+
from forgecli.engine.stages.context_optimizer import ContextOptimizerStage
|
|
15
|
+
from forgecli.engine.stages.execution_engine_stage import ExecutionEngineStage
|
|
16
|
+
from forgecli.engine.stages.git_engine import GitEngineStage
|
|
17
|
+
from forgecli.engine.stages.intent_analyzer import IntentAnalyzerStage
|
|
18
|
+
from forgecli.engine.stages.model_router import ModelRouterStage
|
|
19
|
+
from forgecli.engine.stages.planning_engine import PlanningEngineStage
|
|
20
|
+
from forgecli.engine.stages.repository_analyzer import RepositoryAnalyzerStage
|
|
21
|
+
from forgecli.engine.stages.validation_engine import ValidationEngineStage
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CavemanOptimizerStage",
|
|
25
|
+
"ContextOptimizerStage",
|
|
26
|
+
"ExecutionEngineStage",
|
|
27
|
+
"GitEngineStage",
|
|
28
|
+
"IntentAnalyzerStage",
|
|
29
|
+
"ModelRouterStage",
|
|
30
|
+
"PlanningEngineStage",
|
|
31
|
+
"RepositoryAnalyzerStage",
|
|
32
|
+
"ValidationEngineStage",
|
|
33
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Stage 3 — Caveman Optimizer.
|
|
2
|
+
|
|
3
|
+
Applies the Caveman ruleset to the user prompt to reduce token usage
|
|
4
|
+
in LLM responses. Wraps :func:`forgecli.build.caveman_optimize.caveman_optimization`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from forgecli.build import BuildContext
|
|
12
|
+
from forgecli.build.caveman_optimize import caveman_optimization as _build_caveman_optimize
|
|
13
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
14
|
+
from forgecli.optimizer.caveman import CavemanPromptOptimizer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CavemanOptimizerStage:
|
|
18
|
+
"""Optimize the prompt using Caveman rules."""
|
|
19
|
+
|
|
20
|
+
name = "caveman-optimizer"
|
|
21
|
+
|
|
22
|
+
def __init__(self, optimizer: CavemanPromptOptimizer | None = None) -> None:
|
|
23
|
+
self._optimizer = optimizer
|
|
24
|
+
|
|
25
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
26
|
+
optimizer = self._optimizer or context.engine.extras.get("caveman_optimizer")
|
|
27
|
+
build_ctx = BuildContext(
|
|
28
|
+
prompt=context.engine.prompt,
|
|
29
|
+
root=Path(context.engine.cwd),
|
|
30
|
+
)
|
|
31
|
+
build_ctx.extras["caveman_optimizer"] = optimizer
|
|
32
|
+
|
|
33
|
+
build_ctx = await _build_caveman_optimize(build_ctx)
|
|
34
|
+
|
|
35
|
+
context.engine.caveman_optimized_request = build_ctx.caveman_optimized_request
|
|
36
|
+
context.engine.caveman_optimized_notes = build_ctx.caveman_optimized_notes
|
|
37
|
+
|
|
38
|
+
return StageResult(
|
|
39
|
+
status=StageStatus.SUCCEEDED,
|
|
40
|
+
data={
|
|
41
|
+
"notes": list(build_ctx.caveman_optimized_notes),
|
|
42
|
+
},
|
|
43
|
+
notes=build_ctx.caveman_optimized_notes or ("no caveman optimization applied",),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["CavemanOptimizerStage"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Stage 3 — Context Optimizer.
|
|
2
|
+
|
|
3
|
+
Applies the Ponytail ruleset to the user prompt. Wraps
|
|
4
|
+
:func:`forgecli.build.optimize.ponytail_optimization`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from forgecli.build import BuildContext
|
|
12
|
+
from forgecli.build.optimize import ponytail_optimization as _build_optimize
|
|
13
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
14
|
+
from forgecli.optimizer.ponytail import PromptOptimizer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextOptimizerStage:
|
|
18
|
+
"""Optimize the prompt using Ponytail rules."""
|
|
19
|
+
|
|
20
|
+
name = "context-optimizer"
|
|
21
|
+
|
|
22
|
+
def __init__(self, optimizer: PromptOptimizer | None = None) -> None:
|
|
23
|
+
self._optimizer = optimizer
|
|
24
|
+
|
|
25
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
26
|
+
optimizer = self._optimizer or context.engine.extras.get("optimizer")
|
|
27
|
+
build_ctx = BuildContext(
|
|
28
|
+
prompt=context.engine.prompt,
|
|
29
|
+
root=Path(context.engine.cwd),
|
|
30
|
+
)
|
|
31
|
+
build_ctx.extras["optimizer"] = optimizer
|
|
32
|
+
|
|
33
|
+
build_ctx = await _build_optimize(build_ctx)
|
|
34
|
+
|
|
35
|
+
context.engine.optimized_request = build_ctx.optimized_request
|
|
36
|
+
context.engine.optimized_notes = build_ctx.optimized_notes
|
|
37
|
+
|
|
38
|
+
return StageResult(
|
|
39
|
+
status=StageStatus.SUCCEEDED,
|
|
40
|
+
data={
|
|
41
|
+
"notes": list(build_ctx.optimized_notes),
|
|
42
|
+
},
|
|
43
|
+
notes=build_ctx.optimized_notes or ("no optimization applied",),
|
|
44
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Stage 6 - Execution Engine.
|
|
2
|
+
|
|
3
|
+
Invokes the LLM with the assembled prompt and extracts a unified diff
|
|
4
|
+
from the response. Combines :func:`forgecli.build.llm.llm_call` and
|
|
5
|
+
:func:`forgecli.build.diff_extract.diff_extraction` into a single stage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from forgecli.build import BuildContext
|
|
13
|
+
from forgecli.build.diff_extract import diff_extraction
|
|
14
|
+
from forgecli.build.llm import llm_call
|
|
15
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
16
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Provider, Role
|
|
17
|
+
from forgecli.providers.router import RouteDecision
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExecutionEngineStage:
|
|
21
|
+
"""Send the prompt to the LLM and extract a diff."""
|
|
22
|
+
|
|
23
|
+
name = "execution-engine"
|
|
24
|
+
|
|
25
|
+
def __init__(self, provider: Provider | None = None) -> None:
|
|
26
|
+
self._provider = provider
|
|
27
|
+
|
|
28
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
29
|
+
provider = self._provider or context.engine.extras.get("provider")
|
|
30
|
+
if provider is None:
|
|
31
|
+
return StageResult(
|
|
32
|
+
status=StageStatus.FAILED,
|
|
33
|
+
error="no provider available for LLM call",
|
|
34
|
+
notes=("no provider in extras or constructor",),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
decision: RouteDecision | None = context.engine.extras.get("decision")
|
|
38
|
+
build_ctx = BuildContext(
|
|
39
|
+
prompt=context.engine.prompt,
|
|
40
|
+
root=Path(context.engine.cwd),
|
|
41
|
+
decision=decision,
|
|
42
|
+
)
|
|
43
|
+
if context.engine.retrieval is not None:
|
|
44
|
+
build_ctx.retrieval = context.engine.retrieval.context_text
|
|
45
|
+
if context.engine.optimized_request is not None:
|
|
46
|
+
build_ctx.optimized_request = context.engine.optimized_request
|
|
47
|
+
build_ctx.optimized_notes = context.engine.optimized_notes
|
|
48
|
+
# Merge caveman-optimized system prompt into the final request.
|
|
49
|
+
if (
|
|
50
|
+
context.engine.caveman_optimized_request is not None
|
|
51
|
+
and context.engine.optimized_request is not None
|
|
52
|
+
):
|
|
53
|
+
build_ctx.optimized_request = _merge_caveman_request(
|
|
54
|
+
context.engine.caveman_optimized_request,
|
|
55
|
+
context.engine.optimized_request,
|
|
56
|
+
)
|
|
57
|
+
elif context.engine.caveman_optimized_request is not None:
|
|
58
|
+
build_ctx.optimized_request = context.engine.caveman_optimized_request
|
|
59
|
+
retries = int(context.engine.extras.get("retries", 0))
|
|
60
|
+
build_ctx.extras["provider"] = provider
|
|
61
|
+
build_ctx.extras["retries"] = retries
|
|
62
|
+
|
|
63
|
+
build_ctx = await llm_call(build_ctx)
|
|
64
|
+
build_ctx = await diff_extraction(build_ctx)
|
|
65
|
+
|
|
66
|
+
context.engine.response = build_ctx.response
|
|
67
|
+
context.engine.diff_text = build_ctx.diff_text
|
|
68
|
+
|
|
69
|
+
diff_len = len(build_ctx.diff_text)
|
|
70
|
+
notes: tuple[str, ...] = ()
|
|
71
|
+
if diff_len > 0:
|
|
72
|
+
notes = (f"extracted {diff_len}-char diff",)
|
|
73
|
+
else:
|
|
74
|
+
notes = ("no diff found in LLM response",)
|
|
75
|
+
|
|
76
|
+
return StageResult(
|
|
77
|
+
status=StageStatus.SUCCEEDED,
|
|
78
|
+
data={
|
|
79
|
+
"diff_length": diff_len,
|
|
80
|
+
"has_response": build_ctx.response is not None,
|
|
81
|
+
},
|
|
82
|
+
notes=notes,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _merge_caveman_request(
|
|
87
|
+
caveman_req: ChatRequest,
|
|
88
|
+
ponytail_req: ChatRequest,
|
|
89
|
+
) -> ChatRequest:
|
|
90
|
+
"""Merge caveman and ponytail system prompts into one request."""
|
|
91
|
+
caveman_sys = [
|
|
92
|
+
m for m in caveman_req.messages if m.role is Role.SYSTEM
|
|
93
|
+
]
|
|
94
|
+
ponytail_sys = [
|
|
95
|
+
m for m in ponytail_req.messages if m.role is Role.SYSTEM
|
|
96
|
+
]
|
|
97
|
+
other_messages = [
|
|
98
|
+
m for m in ponytail_req.messages if m.role is not Role.SYSTEM
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
merged_sys_content = "\n\n".join(
|
|
102
|
+
m.content for m in [*caveman_sys, *ponytail_sys] if m.content.strip()
|
|
103
|
+
)
|
|
104
|
+
merged_messages: list[ChatMessage] = [
|
|
105
|
+
ChatMessage(role=Role.SYSTEM, content=merged_sys_content),
|
|
106
|
+
*other_messages,
|
|
107
|
+
]
|
|
108
|
+
return ponytail_req.model_copy(update={"messages": merged_messages})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Stage 8 — Git Engine (placeholder).
|
|
2
|
+
|
|
3
|
+
Stages and optionally pushes the applied changes. Currently
|
|
4
|
+
a pass-through that records intent; actual git integration will follow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitEngineStage:
|
|
13
|
+
"""Stage and push applied changes."""
|
|
14
|
+
|
|
15
|
+
name = "git-engine"
|
|
16
|
+
|
|
17
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
18
|
+
if not context.engine.applied_files:
|
|
19
|
+
return StageResult(
|
|
20
|
+
status=StageStatus.SKIPPED,
|
|
21
|
+
notes=("no files to stage",),
|
|
22
|
+
data={"staged": False},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
context.engine.staged = True
|
|
26
|
+
return StageResult(
|
|
27
|
+
status=StageStatus.SUCCEEDED,
|
|
28
|
+
notes=("files staged (placeholder)",),
|
|
29
|
+
data={"staged": True},
|
|
30
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Stage 1 — Intent Analyzer.
|
|
2
|
+
|
|
3
|
+
Classifies the user's prompt into an :class:`Intent` using the
|
|
4
|
+
:class:`HeuristicIntentClassifier`. The result is stored in
|
|
5
|
+
``context.engine.intent_analysis``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from forgecli.engine.context import IntentAnalysis
|
|
11
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
12
|
+
from forgecli.orchestrator import HeuristicIntentClassifier
|
|
13
|
+
from forgecli.plugins import IntentClassifier
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntentAnalyzerStage:
|
|
17
|
+
"""Classify the user prompt into an :class:`Intent`."""
|
|
18
|
+
|
|
19
|
+
name = "intent-analyzer"
|
|
20
|
+
|
|
21
|
+
def __init__(self, classifier: IntentClassifier | None = None) -> None:
|
|
22
|
+
self._classifier = classifier or HeuristicIntentClassifier()
|
|
23
|
+
|
|
24
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
25
|
+
prediction = self._classifier.classify(context.engine.prompt)
|
|
26
|
+
context.engine.intent_analysis = IntentAnalysis(
|
|
27
|
+
intent=prediction.intent,
|
|
28
|
+
confidence=prediction.confidence,
|
|
29
|
+
rationale=prediction.rationale,
|
|
30
|
+
)
|
|
31
|
+
return StageResult(
|
|
32
|
+
status=StageStatus.SUCCEEDED,
|
|
33
|
+
data={
|
|
34
|
+
"intent": prediction.intent.value,
|
|
35
|
+
"confidence": prediction.confidence,
|
|
36
|
+
},
|
|
37
|
+
notes=prediction.rationale,
|
|
38
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Stage 5 — Model Router.
|
|
2
|
+
|
|
3
|
+
Resolves the user's model choice (or "auto") into a concrete
|
|
4
|
+
(provider, model) pair using :class:`ModelRouter`. The result
|
|
5
|
+
is stored in ``context.engine.model_selection``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from forgecli.engine.context import ModelSelection
|
|
11
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
12
|
+
from forgecli.providers.router import ModelCapabilities, ModelRouter
|
|
13
|
+
from forgecli.providers.router_state import RouterState, load_state
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelRouterStage:
|
|
17
|
+
"""Resolve the model choice and store the selection."""
|
|
18
|
+
|
|
19
|
+
name = "model-router"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
router: ModelRouter | None = None,
|
|
24
|
+
state: RouterState | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._router = router or ModelRouter()
|
|
27
|
+
self._state = state
|
|
28
|
+
|
|
29
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
30
|
+
state = self._state or _resolve_state(context)
|
|
31
|
+
choice = state.choice
|
|
32
|
+
capabilities: ModelCapabilities = context.engine.extras.get(
|
|
33
|
+
"model_capabilities", ModelCapabilities()
|
|
34
|
+
)
|
|
35
|
+
decision = self._router.select(choice, capabilities=capabilities)
|
|
36
|
+
|
|
37
|
+
context.engine.model_selection = ModelSelection(
|
|
38
|
+
provider=decision.provider_name,
|
|
39
|
+
model=decision.model,
|
|
40
|
+
mode=decision.mode.value,
|
|
41
|
+
cost_in=decision.cost_in,
|
|
42
|
+
cost_out=decision.cost_out,
|
|
43
|
+
)
|
|
44
|
+
context.engine.extras["decision"] = decision
|
|
45
|
+
|
|
46
|
+
return StageResult(
|
|
47
|
+
status=StageStatus.SUCCEEDED,
|
|
48
|
+
data={
|
|
49
|
+
"provider": decision.provider_name,
|
|
50
|
+
"model": decision.model,
|
|
51
|
+
"mode": decision.mode.value,
|
|
52
|
+
"cost_in": decision.cost_in,
|
|
53
|
+
"cost_out": decision.cost_out,
|
|
54
|
+
},
|
|
55
|
+
notes=(
|
|
56
|
+
f"route: {decision.provider_name}/{decision.model} "
|
|
57
|
+
f"(mode={decision.mode.value})",
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_state(context: StageContext) -> RouterState:
|
|
63
|
+
paths = context.engine.extras.get("paths")
|
|
64
|
+
if paths is not None:
|
|
65
|
+
return load_state(paths.data_dir / "router.json")
|
|
66
|
+
return RouterState()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Stage 4 — Planning Engine (placeholder).
|
|
2
|
+
|
|
3
|
+
Generates a software plan using :func:`forgecli.planner.software.build_software_plan`.
|
|
4
|
+
Stores the plan in ``context.engine.plan``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
10
|
+
from forgecli.planner.software import PlannerOptions, build_software_plan
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlanningEngineStage:
|
|
14
|
+
"""Generate a deterministic software plan from the prompt."""
|
|
15
|
+
|
|
16
|
+
name = "planning-engine"
|
|
17
|
+
|
|
18
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
19
|
+
self._enabled = enabled
|
|
20
|
+
|
|
21
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
22
|
+
if not self._enabled:
|
|
23
|
+
return StageResult(
|
|
24
|
+
status=StageStatus.SKIPPED,
|
|
25
|
+
notes=("planning disabled",),
|
|
26
|
+
data={"skipped": True},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
prompt = context.engine.prompt
|
|
30
|
+
options = context.engine.extras.get("planner_options", PlannerOptions())
|
|
31
|
+
plan = build_software_plan(prompt, options)
|
|
32
|
+
context.engine.plan = plan
|
|
33
|
+
|
|
34
|
+
return StageResult(
|
|
35
|
+
status=StageStatus.SUCCEEDED,
|
|
36
|
+
data={
|
|
37
|
+
"summary": plan.summary,
|
|
38
|
+
"milestones": len(plan.milestones),
|
|
39
|
+
"tasks": len(plan.tasks) if hasattr(plan, "tasks") else 0,
|
|
40
|
+
},
|
|
41
|
+
notes=(
|
|
42
|
+
f"plan: {plan.summary}",
|
|
43
|
+
f"{len(plan.milestones)} milestones",
|
|
44
|
+
),
|
|
45
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Stage 2 — Repository Analyzer.
|
|
2
|
+
|
|
3
|
+
Queries the configured :class:`RepositoryGraph` for nodes relevant to
|
|
4
|
+
the user prompt. Wraps :func:`forgecli.build.retrieval.graphify_retrieval`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from forgecli.build import BuildContext
|
|
13
|
+
from forgecli.build.retrieval import graphify_retrieval as _build_retrieval
|
|
14
|
+
from forgecli.engine.context import RetrievalResult
|
|
15
|
+
from forgecli.engine.execution import StageContext, StageResult, StageStatus
|
|
16
|
+
from forgecli.graph.repository import RepositoryGraph
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepositoryAnalyzerStage:
|
|
20
|
+
"""Query the repository graph for context relevant to the prompt."""
|
|
21
|
+
|
|
22
|
+
name = "repository-analyzer"
|
|
23
|
+
|
|
24
|
+
def __init__(self, graph: RepositoryGraph | None = None) -> None:
|
|
25
|
+
self._graph = graph
|
|
26
|
+
|
|
27
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
28
|
+
graph = self._graph or context.engine.extras.get("graph")
|
|
29
|
+
build_ctx = BuildContext(
|
|
30
|
+
prompt=context.engine.prompt,
|
|
31
|
+
root=Path(context.engine.cwd),
|
|
32
|
+
)
|
|
33
|
+
build_ctx.extras["graph"] = graph
|
|
34
|
+
|
|
35
|
+
started = time.perf_counter()
|
|
36
|
+
build_ctx = await _build_retrieval(build_ctx)
|
|
37
|
+
elapsed = time.perf_counter() - started
|
|
38
|
+
|
|
39
|
+
context.engine.retrieval = RetrievalResult(
|
|
40
|
+
query=context.engine.prompt,
|
|
41
|
+
context_text=build_ctx.retrieval,
|
|
42
|
+
notes=(
|
|
43
|
+
f"retrieved in {elapsed:.3f}s",
|
|
44
|
+
f"{len(build_ctx.retrieval)} chars",
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
return StageResult(
|
|
48
|
+
status=StageStatus.SUCCEEDED,
|
|
49
|
+
data={
|
|
50
|
+
"retrieval_length": len(build_ctx.retrieval),
|
|
51
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
52
|
+
},
|
|
53
|
+
notes=(f"retrieved {len(build_ctx.retrieval)} chars of context",),
|
|
54
|
+
)
|