autoforge-trainer 1.0.2__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.
- autoforge/__init__.py +3 -0
- autoforge/agents/__init__.py +0 -0
- autoforge/agents/loop.py +168 -0
- autoforge/agents/providers.py +272 -0
- autoforge/api.py +330 -0
- autoforge/cli/__init__.py +0 -0
- autoforge/cli/agent.py +102 -0
- autoforge/cli/data.py +42 -0
- autoforge/cli/hpo.py +98 -0
- autoforge/cli/main.py +112 -0
- autoforge/cli/models.py +172 -0
- autoforge/cli/output.py +40 -0
- autoforge/cli/packs.py +185 -0
- autoforge/cli/quickstart.py +61 -0
- autoforge/cli/runs.py +233 -0
- autoforge/config.py +31 -0
- autoforge/data/__init__.py +0 -0
- autoforge/data/profile.py +99 -0
- autoforge/data/registry.py +47 -0
- autoforge/engine/__init__.py +0 -0
- autoforge/engine/context.py +125 -0
- autoforge/engine/diagnose.py +134 -0
- autoforge/engine/hpo.py +355 -0
- autoforge/engine/runner.py +380 -0
- autoforge/engine/store.py +207 -0
- autoforge/engine/validate.py +172 -0
- autoforge/engine/worker.py +68 -0
- autoforge/errors.py +17 -0
- autoforge/examples/__init__.py +0 -0
- autoforge/examples/churn.csv +601 -0
- autoforge/gui/__init__.py +0 -0
- autoforge/gui/app.py +243 -0
- autoforge/gui/static/app.js +546 -0
- autoforge/gui/static/index.html +26 -0
- autoforge/gui/static/style.css +83 -0
- autoforge/hardware/__init__.py +0 -0
- autoforge/hardware/detect.py +161 -0
- autoforge/packs/__init__.py +0 -0
- autoforge/packs/authoring.py +203 -0
- autoforge/packs/errors.py +42 -0
- autoforge/packs/index.py +149 -0
- autoforge/packs/installer.py +241 -0
- autoforge/packs/manifest.py +192 -0
- autoforge/policy/__init__.py +0 -0
- autoforge/policy/privacy.py +68 -0
- autoforge/registry/__init__.py +0 -0
- autoforge/registry/models.py +350 -0
- autoforge/schemas/__init__.py +0 -0
- autoforge/schemas/core.py +137 -0
- autoforge/serve/__init__.py +0 -0
- autoforge/serve/mcp_server.py +293 -0
- autoforge/serve/model_mcp.py +98 -0
- autoforge/serve/rest.py +87 -0
- autoforge_trainer-1.0.2.dist-info/METADATA +216 -0
- autoforge_trainer-1.0.2.dist-info/RECORD +58 -0
- autoforge_trainer-1.0.2.dist-info/WHEEL +4 -0
- autoforge_trainer-1.0.2.dist-info/entry_points.txt +2 -0
- autoforge_trainer-1.0.2.dist-info/licenses/LICENSE +21 -0
autoforge/api.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Core API shared by the MCP server (and later REST / GUI).
|
|
2
|
+
|
|
3
|
+
Functions return JSON-serializable data and raise AutoForgeError subclasses;
|
|
4
|
+
transports wrap them in the Envelope shape. `actor` says who acts (for the
|
|
5
|
+
audit log); `brain` decides what data the caller may see (privacy guard).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from autoforge import config
|
|
16
|
+
from autoforge.data import registry
|
|
17
|
+
from autoforge.data.profile import profile
|
|
18
|
+
from autoforge.engine import runner
|
|
19
|
+
from autoforge.engine.store import RunStore, list_runs
|
|
20
|
+
from autoforge.engine.validate import check_plan, parse_plan
|
|
21
|
+
from autoforge.errors import AutoForgeError
|
|
22
|
+
from autoforge.hardware.detect import detect_hardware
|
|
23
|
+
from autoforge.packs import installer
|
|
24
|
+
from autoforge.packs.index import load_index, search
|
|
25
|
+
from autoforge.policy.privacy import require_access
|
|
26
|
+
from autoforge.schemas.core import BrainKind
|
|
27
|
+
|
|
28
|
+
TRAINER_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}\.py$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BadRequest(AutoForgeError):
|
|
32
|
+
code = "BAD_REQUEST"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- hardware / schema ----------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hardware() -> dict:
|
|
39
|
+
hw = detect_hardware()
|
|
40
|
+
return hw.model_dump() | {"best_device": hw.best_device}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------- packs ----------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pack_search(query: str | None = None, task: str | None = None, fit: bool = True) -> dict:
|
|
47
|
+
hw = detect_hardware() if fit else None
|
|
48
|
+
index, notes = load_index()
|
|
49
|
+
lock = installer.read_lock()
|
|
50
|
+
hits = [
|
|
51
|
+
h.model_dump(mode="json") | {"installed": h.entry.name in lock}
|
|
52
|
+
for h in search(index, task=task, query=query, hardware=hw)
|
|
53
|
+
]
|
|
54
|
+
return {"hits": hits, "notes": notes}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pack_install(source: str, force: bool = False) -> dict:
|
|
58
|
+
return installer.install(source, force=force).model_dump()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pack_info(name: str, knowledge: bool = True) -> dict:
|
|
62
|
+
rec, pack = installer.get_installed(name)
|
|
63
|
+
data = {
|
|
64
|
+
"installed": rec.model_dump(),
|
|
65
|
+
"manifest": pack.manifest.model_dump(),
|
|
66
|
+
"recipes": [r.model_dump() for r in pack.recipes],
|
|
67
|
+
"missing_dependencies": installer.missing_dependencies(pack),
|
|
68
|
+
}
|
|
69
|
+
if knowledge:
|
|
70
|
+
data["knowledge"] = pack.knowledge()
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pack_list() -> list[dict]:
|
|
75
|
+
return [r.model_dump() for r in installer.read_lock().values()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------- data ----------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def data_profile(path: str, brain: BrainKind) -> dict:
|
|
82
|
+
p = Path(path).expanduser()
|
|
83
|
+
if not p.exists():
|
|
84
|
+
raise BadRequest(f"path not found: {path}")
|
|
85
|
+
sens = registry.sensitivity_of(p)
|
|
86
|
+
access = require_access(brain, sens, str(p.resolve()))
|
|
87
|
+
return profile(p, access) | {"sensitivity": sens.value}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------- trainers written by AI ----------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def trainers_dir() -> Path:
|
|
94
|
+
return config.home() / "workspace" / "trainers"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def trainer_write(filename: str, source: str) -> dict:
|
|
98
|
+
"""Save AI-written trainer code to the workspace and report its top-level functions."""
|
|
99
|
+
if not TRAINER_NAME_RE.fullmatch(filename):
|
|
100
|
+
raise BadRequest("filename must look like 'my_trainer.py' (letters, digits, '_')")
|
|
101
|
+
try:
|
|
102
|
+
tree = ast.parse(source)
|
|
103
|
+
except SyntaxError as e:
|
|
104
|
+
raise BadRequest(f"syntax error: {e}", hint="Fix the code and write it again.")
|
|
105
|
+
path = trainers_dir() / filename
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
path.write_text(source, encoding="utf-8")
|
|
108
|
+
funcs = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)]
|
|
109
|
+
return {
|
|
110
|
+
"path": str(path),
|
|
111
|
+
"functions": funcs,
|
|
112
|
+
"use_in_plan": f"custom_code_path: {path}:{'train' if 'train' in funcs else (funcs[0] if funcs else 'train')}",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------- plans / runs ----------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _plan(plan: dict[str, Any], base: Path | None):
|
|
120
|
+
return parse_plan(plan, base or Path.cwd())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def plan_validate(plan: dict[str, Any], actor: str, brain: BrainKind, base: Path | None = None) -> dict:
|
|
124
|
+
p = _plan(plan, base)
|
|
125
|
+
runner.require_data_access(p, actor, brain)
|
|
126
|
+
return check_plan(p).model_dump() | {"resolved_data_path": p.data_path}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_start(plan: dict[str, Any], actor: str, brain: BrainKind, base: Path | None = None) -> dict:
|
|
130
|
+
store = runner.create_run(_plan(plan, base), actor, brain=brain)
|
|
131
|
+
runner.start(store, actor)
|
|
132
|
+
return runner.status(store).model_dump(mode="json")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_status(run_id: str) -> dict:
|
|
136
|
+
return runner.status(RunStore.open(run_id)).model_dump(mode="json")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_wait(run_id: str, timeout_seconds: float = 30) -> dict:
|
|
140
|
+
timeout = max(0.0, min(timeout_seconds, 120.0))
|
|
141
|
+
return runner.wait(RunStore.open(run_id), timeout).model_dump(mode="json")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_list(limit: int = 20) -> list[dict]:
|
|
145
|
+
return [runner.status(s).model_dump(mode="json") for s in list_runs()[:limit]]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_metrics(run_id: str, last: int = 5) -> dict:
|
|
149
|
+
return runner.metrics_summary(RunStore.open(run_id), last)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_logs(run_id: str, tail: int = 50) -> dict:
|
|
153
|
+
return {"lines": RunStore.open(run_id).logs_tail(max(1, min(tail, 500)))}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_audit(run_id: str) -> list[dict]:
|
|
157
|
+
return RunStore.open(run_id).audit_log()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_pause(run_id: str, actor: str) -> dict:
|
|
161
|
+
return runner.pause(RunStore.open(run_id), actor).model_dump(mode="json")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_resume(run_id: str, actor: str) -> dict:
|
|
165
|
+
return runner.resume(RunStore.open(run_id), actor).model_dump(mode="json")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run_stop(run_id: str, actor: str, grace_seconds: float = 10.0) -> dict:
|
|
169
|
+
return runner.stop(RunStore.open(run_id), actor, grace_seconds).model_dump(mode="json")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_retry(run_id: str, actor: str, brain: BrainKind) -> dict:
|
|
173
|
+
store = runner.retry(RunStore.open(run_id), actor, brain=brain)
|
|
174
|
+
runner.start(store, actor)
|
|
175
|
+
return runner.status(store).model_dump(mode="json")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------- P5: diagnosis, live adjustment, comparison, hyperparameter search ----------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_diagnose(run_id: str) -> dict:
|
|
182
|
+
from autoforge.engine.diagnose import diagnose
|
|
183
|
+
|
|
184
|
+
return diagnose(RunStore.open(run_id))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_adjust(run_id: str, actor: str, changes: dict[str, Any]) -> dict:
|
|
188
|
+
return runner.adjust(RunStore.open(run_id), actor, changes)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_compare(run_ids: list[str], metric: str | None = None) -> dict:
|
|
192
|
+
if not run_ids:
|
|
193
|
+
raise BadRequest("give at least one run_id")
|
|
194
|
+
return runner.compare(run_ids, metric)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def hpo_start(
|
|
198
|
+
plan: dict[str, Any], metric: str, n_trials: int, actor: str, brain: BrainKind,
|
|
199
|
+
direction: str | None = None, search_space: dict[str, Any] | None = None,
|
|
200
|
+
timeout_minutes: float | None = None, base: Path | None = None,
|
|
201
|
+
) -> dict:
|
|
202
|
+
from autoforge.engine import hpo
|
|
203
|
+
|
|
204
|
+
if direction not in (None, "maximize", "minimize"):
|
|
205
|
+
raise BadRequest("direction must be 'maximize' or 'minimize'")
|
|
206
|
+
store = hpo.create_study(_plan(plan, base), metric, n_trials, actor, brain, direction, search_space, timeout_minutes)
|
|
207
|
+
hpo.start_study(store)
|
|
208
|
+
return hpo.status(store).model_dump(mode="json")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def hpo_status(study_id: str) -> dict:
|
|
212
|
+
from autoforge.engine import hpo
|
|
213
|
+
|
|
214
|
+
return hpo.status(hpo.StudyStore.open(study_id)).model_dump(mode="json")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def hpo_wait(study_id: str, timeout_seconds: float = 60) -> dict:
|
|
218
|
+
from autoforge.engine import hpo
|
|
219
|
+
|
|
220
|
+
return hpo.wait(hpo.StudyStore.open(study_id), max(0.0, min(timeout_seconds, 120.0))).model_dump(mode="json")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def hpo_stop(study_id: str, actor: str) -> dict:
|
|
224
|
+
from autoforge.engine import hpo
|
|
225
|
+
|
|
226
|
+
return hpo.stop(hpo.StudyStore.open(study_id), actor).model_dump(mode="json")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def hpo_list(limit: int = 20) -> list[dict]:
|
|
230
|
+
from autoforge.engine import hpo
|
|
231
|
+
|
|
232
|
+
out = []
|
|
233
|
+
for s in hpo.list_studies()[:limit]:
|
|
234
|
+
v = hpo.status(s).model_dump(mode="json")
|
|
235
|
+
v.pop("trials", None)
|
|
236
|
+
out.append(v)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------- P6: model registry and serving ----------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def model_register(run_id: str, name: str, actor: str, description: str = "") -> dict:
|
|
244
|
+
from autoforge.registry import models as reg
|
|
245
|
+
|
|
246
|
+
return reg.register(run_id, name, actor, description).model_dump(mode="json")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def model_list() -> list[dict]:
|
|
250
|
+
from autoforge.registry import models as reg
|
|
251
|
+
|
|
252
|
+
return [{"model": c.ref, "task": c.task.task_type, "target": c.task.target, "metrics": c.metrics,
|
|
253
|
+
"servable": c.servable, "run_id": c.run_id, "versions": reg.versions(c.name)} for c in reg.list_models()]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def model_info(ref: str) -> dict:
|
|
257
|
+
from autoforge.registry import models as reg
|
|
258
|
+
|
|
259
|
+
card = reg.get_card(ref)
|
|
260
|
+
return card.model_dump(mode="json") | {"versions": reg.versions(card.name),
|
|
261
|
+
"export_formats": ["bundle", *card.exporters]}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def model_predict(ref: str, rows: list[dict[str, Any]]) -> dict:
|
|
265
|
+
from autoforge.registry import models as reg
|
|
266
|
+
|
|
267
|
+
return reg.predict(ref, rows)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def model_export(ref: str, fmt: str, out: str | None = None) -> dict:
|
|
271
|
+
from autoforge.registry import models as reg
|
|
272
|
+
|
|
273
|
+
card = reg.get_card(ref)
|
|
274
|
+
suffix = {"bundle": "zip"}.get(fmt, fmt)
|
|
275
|
+
path = Path(out) if out else config.home() / "exports" / f"{card.name}-v{card.version}.{suffix}"
|
|
276
|
+
return reg.export(ref, fmt, path)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------- P7: pack authoring ----------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def pack_create(name: str, files: dict[str, str], overwrite: bool = False) -> dict:
|
|
283
|
+
from autoforge.packs.authoring import create_from_files
|
|
284
|
+
|
|
285
|
+
return create_from_files(name, files, overwrite)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def pack_test(target: str) -> dict:
|
|
289
|
+
from autoforge.packs.authoring import test_pack
|
|
290
|
+
|
|
291
|
+
path = target if Path(target).is_dir() else installer.get_installed(target)[0].path
|
|
292
|
+
return test_pack(path)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------- P8: helpers for the GUI ----------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_series(run_id: str, max_points: int = 500) -> dict[str, list[list[float]]]:
|
|
299
|
+
"""Per-step metric curves {name: [[step, value], ...]} for charts. Final metrics (logged without a
|
|
300
|
+
step) are not curves and are left out; so are series with a single point."""
|
|
301
|
+
series: dict[str, list[list[float]]] = {}
|
|
302
|
+
for e in RunStore.open(run_id).events():
|
|
303
|
+
if e.get("type") == "metric" and e.get("step") is not None:
|
|
304
|
+
series.setdefault(e["name"], []).append([e["step"], e["value"]])
|
|
305
|
+
series = {k: v for k, v in series.items() if len(v) >= 2}
|
|
306
|
+
for name, pts in series.items():
|
|
307
|
+
if len(pts) > max_points: # keep the shape, drop points evenly; always keep the last
|
|
308
|
+
stride = len(pts) / max_points
|
|
309
|
+
series[name] = [pts[int(k * stride)] for k in range(max_points - 1)] + [pts[-1]]
|
|
310
|
+
return series
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def task_catalog() -> list[dict]:
|
|
314
|
+
"""Task types available from installed packs and the index, with the packs offering each."""
|
|
315
|
+
tasks: dict[str, set[str]] = {}
|
|
316
|
+
for rec in installer.read_lock().values():
|
|
317
|
+
try:
|
|
318
|
+
_, pack = installer.get_installed(rec.name)
|
|
319
|
+
except AutoForgeError:
|
|
320
|
+
continue
|
|
321
|
+
for t in pack.manifest.tasks:
|
|
322
|
+
tasks.setdefault(t, set()).add(pack.manifest.name)
|
|
323
|
+
try:
|
|
324
|
+
index, _ = load_index()
|
|
325
|
+
for e in index.packs:
|
|
326
|
+
for t in e.tasks:
|
|
327
|
+
tasks.setdefault(t, set()).add(e.name)
|
|
328
|
+
except AutoForgeError:
|
|
329
|
+
pass
|
|
330
|
+
return [{"task": t, "packs": sorted(p)} for t, p in sorted(tasks.items())]
|
|
File without changes
|
autoforge/cli/agent.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""`autoforge train "<goal>"`: the built-in agent plans and trains from one sentence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from autoforge.agents.loop import AgentEvent, run_agent
|
|
12
|
+
from autoforge.agents.providers import ToolCall, make_provider, parse_brain
|
|
13
|
+
from autoforge.cli.output import emit, errors_as_envelope, fail
|
|
14
|
+
|
|
15
|
+
_SHORT = {"hardware_profile", "pack_list", "run_status", "run_wait", "run_metrics", "run_list"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _describe_plan(call: ToolCall) -> str:
|
|
19
|
+
if call.name == "run_retry":
|
|
20
|
+
return f"retry run {call.arguments.get('run_id')}"
|
|
21
|
+
if call.name == "hpo_start":
|
|
22
|
+
a = call.arguments
|
|
23
|
+
head = (f" hyperparameter search: {a.get('n_trials', 10)} trials, optimise {a.get('metric')}"
|
|
24
|
+
f" ({a.get('direction') or 'auto direction'})\n")
|
|
25
|
+
return head + _describe_plan(ToolCall(call.id, "run_start", {"plan": a.get("plan")}))
|
|
26
|
+
plan = call.arguments.get("plan") or {}
|
|
27
|
+
task = plan.get("task") or {}
|
|
28
|
+
method = (f"{plan.get('pack')} / {plan.get('recipe')}" if plan.get("pack")
|
|
29
|
+
else f"custom code {plan.get('custom_code_path')}")
|
|
30
|
+
lines = [
|
|
31
|
+
f" task: {task.get('task_type')} (target: {task.get('target')}, metric: {task.get('metric')})",
|
|
32
|
+
f" data: {plan.get('data_path')}",
|
|
33
|
+
f" method: {method}",
|
|
34
|
+
f" device: {plan.get('device', 'auto')}",
|
|
35
|
+
f" hyperparameters: {json.dumps(plan.get('hyperparameters') or {}, ensure_ascii=False)}",
|
|
36
|
+
]
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _ask(call: ToolCall) -> tuple[bool, str]:
|
|
41
|
+
typer.echo("\n" + typer.style("The agent wants to start training:", bold=True))
|
|
42
|
+
typer.echo(_describe_plan(call))
|
|
43
|
+
answer = typer.prompt("Start? [y = yes / n = no / or type feedback for the agent]", default="n").strip()
|
|
44
|
+
if answer.lower() in ("y", "yes"):
|
|
45
|
+
return True, ""
|
|
46
|
+
return False, "" if answer.lower() in ("n", "no") else answer
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _printer(event: AgentEvent) -> None:
|
|
50
|
+
d = event.data
|
|
51
|
+
if event.kind == "text":
|
|
52
|
+
typer.echo("\n" + d["text"])
|
|
53
|
+
elif event.kind == "tool_call":
|
|
54
|
+
args = "" if event.data["name"] in _SHORT else json.dumps(d["arguments"], ensure_ascii=False)[:160]
|
|
55
|
+
typer.echo(typer.style(f" → {d['name']} {args}", dim=True))
|
|
56
|
+
elif event.kind == "tool_result" and not d["ok"]:
|
|
57
|
+
typer.echo(typer.style(f" ✗ {d['name']}: {d['error']}", fg="yellow"))
|
|
58
|
+
elif event.kind == "info":
|
|
59
|
+
typer.echo(typer.style(d["message"], fg="yellow"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def train(
|
|
63
|
+
goal: str = typer.Argument(help="What you want, in plain words. e.g. 'predict churned from this CSV'"),
|
|
64
|
+
data: Path = typer.Option(None, "--data", "-d", help="Dataset file or directory."),
|
|
65
|
+
brain: str = typer.Option(
|
|
66
|
+
None, "--brain", "-b",
|
|
67
|
+
help="provider:model. anthropic:claude-opus-5-5 (default), openai:<model>, ollama:<model>, local:<model>. "
|
|
68
|
+
"Default from $AUTOFORGE_BRAIN.",
|
|
69
|
+
),
|
|
70
|
+
base_url: str = typer.Option(None, "--base-url", help="OpenAI-compatible endpoint for openai:/local:/ollama:."),
|
|
71
|
+
api_key_env: str = typer.Option(None, "--api-key-env", help="Env var holding the API key for openai: (default OPENAI_API_KEY)."),
|
|
72
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask before starting training."),
|
|
73
|
+
max_steps: int = typer.Option(40, "--max-steps", help="Maximum model turns."),
|
|
74
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable result (requires --yes)."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Let an AI analyse your data and hardware, choose a method, train and report — from one sentence."""
|
|
77
|
+
if as_json and not yes:
|
|
78
|
+
fail(True, "BAD_REQUEST", "--json needs --yes (no interactive approval possible)")
|
|
79
|
+
with errors_as_envelope(as_json):
|
|
80
|
+
spec = parse_brain(brain, base_url, api_key_env)
|
|
81
|
+
provider = make_provider(spec)
|
|
82
|
+
if not as_json:
|
|
83
|
+
where = "local model" if spec.is_local else "cloud model"
|
|
84
|
+
typer.echo(typer.style(f"Brain: {spec.provider}:{spec.model} ({where}) — actor {spec.actor}", dim=True))
|
|
85
|
+
with errors_as_envelope(as_json):
|
|
86
|
+
result = asyncio.run(run_agent(
|
|
87
|
+
goal,
|
|
88
|
+
provider,
|
|
89
|
+
actor=spec.actor,
|
|
90
|
+
brain=spec.brain_kind,
|
|
91
|
+
approve=None if yes else _ask,
|
|
92
|
+
on_event=None if as_json else _printer,
|
|
93
|
+
max_steps=max_steps,
|
|
94
|
+
data_path=str(data) if data else None,
|
|
95
|
+
))
|
|
96
|
+
data_out = {"stop": result.stop, "steps": result.steps, "runs": result.runs,
|
|
97
|
+
"final_text": result.final_text, "transcript": result.transcript}
|
|
98
|
+
human = (f"\n— {result.stop} after {result.steps} step(s); runs: {', '.join(result.runs) or 'none'}"
|
|
99
|
+
f"\n transcript: {result.transcript}")
|
|
100
|
+
emit(as_json, data_out, human)
|
|
101
|
+
if result.stop not in ("end",):
|
|
102
|
+
raise typer.Exit(1)
|
autoforge/cli/data.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""`autoforge data ...`: mark dataset sensitivity (humans only) and profile data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from autoforge.cli.output import JsonOpt, emit, errors_as_envelope
|
|
10
|
+
from autoforge.data import registry
|
|
11
|
+
|
|
12
|
+
data_app = typer.Typer(help="Dataset sensitivity and profiling.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@data_app.command("mark")
|
|
16
|
+
def mark(
|
|
17
|
+
path: Path,
|
|
18
|
+
sensitivity: registry.Sensitivity = typer.Option(..., "--sensitivity", "-s",
|
|
19
|
+
help="public / internal / sensitive"),
|
|
20
|
+
as_json: bool = JsonOpt,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Mark a file or directory. Sensitive data can only be used by a human or a local-model AI."""
|
|
23
|
+
key = registry.mark(path, sensitivity)
|
|
24
|
+
emit(as_json, {"path": key, "sensitivity": sensitivity.value}, f"{key}: {sensitivity.value}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@data_app.command("list")
|
|
28
|
+
def list_marks(as_json: bool = JsonOpt) -> None:
|
|
29
|
+
"""All marked paths (unmarked data is 'internal')."""
|
|
30
|
+
marks = registry.all_marks()
|
|
31
|
+
emit(as_json, marks, "\n".join(f"{v:<10} {k}" for k, v in marks.items()) or "(nothing marked; default: internal)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@data_app.command("profile")
|
|
35
|
+
def profile(path: Path, as_json: bool = JsonOpt) -> None:
|
|
36
|
+
"""Profile a dataset (as a human: full access, including sample rows)."""
|
|
37
|
+
from autoforge import api
|
|
38
|
+
from autoforge.schemas.core import BrainKind
|
|
39
|
+
|
|
40
|
+
with errors_as_envelope(as_json):
|
|
41
|
+
data = api.data_profile(str(path), BrainKind.human)
|
|
42
|
+
emit(as_json, data)
|
autoforge/cli/hpo.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`autoforge hpo ...`: hyperparameter search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from autoforge.cli.output import JsonOpt, emit, errors_as_envelope
|
|
12
|
+
from autoforge.cli.runs import ActorOpt, _actor
|
|
13
|
+
|
|
14
|
+
hpo_app = typer.Typer(help="Hyperparameter search (each trial is a normal run).", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _line(v: dict) -> str:
|
|
18
|
+
best = "-" if v["best_value"] is None else f"{v['best_value']:.4g}"
|
|
19
|
+
base = "-" if v["baseline_value"] is None else f"{v['baseline_value']:.4g}"
|
|
20
|
+
imp = "" if v["improvement"] is None else f" ({v['improvement']:+.4g} vs baseline)"
|
|
21
|
+
return (f"{v['study_id']:<34} {v['status']:<10} {v['completed']}/{v['n_trials']} trials, "
|
|
22
|
+
f"{v['metric']} best={best} baseline={base}{imp}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@hpo_app.command("start")
|
|
26
|
+
def start(
|
|
27
|
+
plan_file: Path,
|
|
28
|
+
metric: str = typer.Option(..., "--metric", "-m", help="Run metric to optimise, e.g. f1_macro or rmse."),
|
|
29
|
+
trials: int = typer.Option(20, "--trials", "-n"),
|
|
30
|
+
direction: str = typer.Option(None, "--direction", help="maximize / minimize (default: inferred from the metric)."),
|
|
31
|
+
space_file: Path = typer.Option(None, "--space", help="YAML search space (default: the recipe's search_space)."),
|
|
32
|
+
timeout_min: float = typer.Option(None, "--timeout-min", help="Stop starting new trials after N minutes."),
|
|
33
|
+
wait: bool = typer.Option(False, "--wait", help="Block and print each finished trial."),
|
|
34
|
+
actor: str = ActorOpt,
|
|
35
|
+
as_json: bool = JsonOpt,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Start a search. Trial 0 is the plan's own hyperparameters, the baseline."""
|
|
38
|
+
from autoforge import api
|
|
39
|
+
from autoforge.policy.privacy import brain_from_actor
|
|
40
|
+
|
|
41
|
+
space = yaml.safe_load(space_file.read_text(encoding="utf-8")) if space_file else None
|
|
42
|
+
who = _actor(actor)
|
|
43
|
+
with errors_as_envelope(as_json):
|
|
44
|
+
plan = yaml.safe_load(plan_file.read_text(encoding="utf-8"))
|
|
45
|
+
view = api.hpo_start(plan, metric, trials, who, brain_from_actor(who), direction, space, timeout_min,
|
|
46
|
+
base=plan_file.resolve().parent)
|
|
47
|
+
sid = view["study_id"]
|
|
48
|
+
if not wait:
|
|
49
|
+
emit(as_json, view, f"Started {sid}\nPoll with: autoforge hpo status {sid}")
|
|
50
|
+
return
|
|
51
|
+
if not as_json:
|
|
52
|
+
typer.echo(f"Started {sid}")
|
|
53
|
+
seen = 0
|
|
54
|
+
while True:
|
|
55
|
+
view = api.hpo_status(sid)
|
|
56
|
+
if not as_json:
|
|
57
|
+
for t in view["trials"][seen:]:
|
|
58
|
+
tag = " (baseline)" if t.get("baseline") else ""
|
|
59
|
+
res = f"{metric}={t['value']:.4g}" if "value" in t else f"failed: {t.get('error')}"
|
|
60
|
+
typer.echo(f" trial {t['number']}{tag}: {res} {t['params']}")
|
|
61
|
+
seen = len(view["trials"])
|
|
62
|
+
if view["status"] not in ("pending", "running"):
|
|
63
|
+
break
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
emit(as_json, view, _line(view) + (f"\nbest params: {view['best_params']} run: {view['best_run_id']}"
|
|
66
|
+
if view["best_params"] is not None else ""))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@hpo_app.command("status")
|
|
70
|
+
def status(study_id: str, as_json: bool = JsonOpt) -> None:
|
|
71
|
+
"""Progress, best result and every trial."""
|
|
72
|
+
from autoforge import api
|
|
73
|
+
|
|
74
|
+
with errors_as_envelope(as_json):
|
|
75
|
+
v = api.hpo_status(study_id)
|
|
76
|
+
lines = [_line(v)] + [
|
|
77
|
+
f" trial {t['number']}{' (baseline)' if t.get('baseline') else ''}: "
|
|
78
|
+
f"{t.get('value', 'failed')} {t['params']}" for t in v["trials"]]
|
|
79
|
+
emit(as_json, v, "\n".join(lines))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@hpo_app.command("list")
|
|
83
|
+
def list_cmd(as_json: bool = JsonOpt) -> None:
|
|
84
|
+
"""Recent searches."""
|
|
85
|
+
from autoforge import api
|
|
86
|
+
|
|
87
|
+
rows = api.hpo_list()
|
|
88
|
+
emit(as_json, rows, "\n".join(map(_line, rows)) or "(no searches)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@hpo_app.command("stop")
|
|
92
|
+
def stop(study_id: str, actor: str = ActorOpt, as_json: bool = JsonOpt) -> None:
|
|
93
|
+
"""Stop a search and its current trial; finished trials are kept."""
|
|
94
|
+
from autoforge import api
|
|
95
|
+
|
|
96
|
+
with errors_as_envelope(as_json):
|
|
97
|
+
v = api.hpo_stop(study_id, _actor(actor))
|
|
98
|
+
emit(as_json, v, _line(v))
|
autoforge/cli/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""AutoForge CLI. Every command supports --json and emits the Envelope shape,
|
|
2
|
+
so AI agents can call it as reliably as the MCP server."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from autoforge import __version__
|
|
9
|
+
from autoforge.cli.output import JsonOpt, emit, fail
|
|
10
|
+
from autoforge.cli.packs import index_app, pack_app
|
|
11
|
+
from autoforge.cli.agent import train
|
|
12
|
+
from autoforge.cli.data import data_app
|
|
13
|
+
from autoforge.cli.hpo import hpo_app
|
|
14
|
+
from autoforge.cli.models import gui, model_app, serve, serve_mcp
|
|
15
|
+
from autoforge.cli.quickstart import quickstart
|
|
16
|
+
from autoforge.cli.runs import plan_app, run_app
|
|
17
|
+
from autoforge.hardware.detect import detect_hardware
|
|
18
|
+
from autoforge.engine.runner import RunView
|
|
19
|
+
from autoforge.registry.models import ModelCard
|
|
20
|
+
from autoforge.packs.index import IndexEntry, PackIndex
|
|
21
|
+
from autoforge.packs.manifest import PackManifest, RecipeSpec
|
|
22
|
+
from autoforge.schemas.core import SCHEMAS
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="AutoForge: AI that trains AI.", no_args_is_help=True)
|
|
25
|
+
app.add_typer(pack_app, name="pack")
|
|
26
|
+
app.add_typer(index_app, name="index")
|
|
27
|
+
app.add_typer(plan_app, name="plan")
|
|
28
|
+
app.add_typer(run_app, name="run")
|
|
29
|
+
app.add_typer(data_app, name="data")
|
|
30
|
+
app.add_typer(hpo_app, name="hpo")
|
|
31
|
+
app.add_typer(model_app, name="model")
|
|
32
|
+
app.command()(serve)
|
|
33
|
+
app.command("serve-mcp")(serve_mcp)
|
|
34
|
+
app.command()(train)
|
|
35
|
+
app.command()(gui)
|
|
36
|
+
app.command()(quickstart)
|
|
37
|
+
|
|
38
|
+
ALL_SCHEMAS = SCHEMAS | {
|
|
39
|
+
"PackManifest": PackManifest,
|
|
40
|
+
"RecipeSpec": RecipeSpec,
|
|
41
|
+
"PackIndex": PackIndex,
|
|
42
|
+
"IndexEntry": IndexEntry,
|
|
43
|
+
"RunView": RunView,
|
|
44
|
+
"ModelCard": ModelCard,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def version(as_json: bool = JsonOpt) -> None:
|
|
50
|
+
"""Show the AutoForge version."""
|
|
51
|
+
emit(as_json, {"version": __version__}, f"autoforge {__version__}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def doctor(as_json: bool = JsonOpt) -> None:
|
|
56
|
+
"""Detect CPU / RAM / NVIDIA GPU / CUDA and report the training device."""
|
|
57
|
+
hw = detect_hardware()
|
|
58
|
+
data = hw.model_dump() | {"best_device": hw.best_device}
|
|
59
|
+
if as_json:
|
|
60
|
+
emit(True, data)
|
|
61
|
+
return
|
|
62
|
+
lines = [
|
|
63
|
+
f"OS: {hw.os} (Python {hw.python_version})",
|
|
64
|
+
f"CPU: {hw.cpu.model} ({hw.cpu.physical_cores}C/{hw.cpu.logical_cores}T)",
|
|
65
|
+
f"RAM: {hw.ram_available_gb} / {hw.ram_total_gb} GB available",
|
|
66
|
+
f"Disk: {hw.disk_free_gb} GB free",
|
|
67
|
+
]
|
|
68
|
+
if hw.gpus:
|
|
69
|
+
for g in hw.gpus:
|
|
70
|
+
lines.append(
|
|
71
|
+
f"GPU {g.index}: {g.name}, {g.vram_total_gb} GB VRAM, "
|
|
72
|
+
f"CC {g.compute_capability}, driver {g.driver_version}"
|
|
73
|
+
)
|
|
74
|
+
lines.append(f"CUDA: {hw.cuda_version}")
|
|
75
|
+
else:
|
|
76
|
+
lines.append("GPU: none (NVIDIA)")
|
|
77
|
+
lines.append(f"Torch: {hw.torch_version or 'not installed'} (CUDA usable: {hw.torch_cuda_available})")
|
|
78
|
+
lines.append(f"Device: {hw.best_device}")
|
|
79
|
+
for n in hw.detection_notes:
|
|
80
|
+
lines.append(f" note: {n}")
|
|
81
|
+
emit(False, human="\n".join(lines))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def mcp(
|
|
86
|
+
actor: str = typer.Option(
|
|
87
|
+
"ai:mcp", "--actor",
|
|
88
|
+
help="Recorded in audit logs. Use 'ai:local:<model>' only if the MCP client is driven by a local model.",
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Run the MCP server over stdio (for Claude Desktop, Claude Code and other MCP clients)."""
|
|
92
|
+
from autoforge.serve.mcp_server import main as serve
|
|
93
|
+
|
|
94
|
+
serve(actor)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def schema(
|
|
99
|
+
name: str = typer.Argument(None, help="Schema name; omit to list all."),
|
|
100
|
+
as_json: bool = JsonOpt,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Print the JSON Schema of a core data structure."""
|
|
103
|
+
if name is None:
|
|
104
|
+
emit(as_json, sorted(ALL_SCHEMAS), "\n".join(sorted(ALL_SCHEMAS)))
|
|
105
|
+
return
|
|
106
|
+
if name not in ALL_SCHEMAS:
|
|
107
|
+
fail(as_json, "UNKNOWN_SCHEMA", f"No schema named '{name}'", f"Choose from: {', '.join(sorted(ALL_SCHEMAS))}")
|
|
108
|
+
emit(as_json, ALL_SCHEMAS[name].model_json_schema())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
app()
|