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.
Files changed (58) hide show
  1. autoforge/__init__.py +3 -0
  2. autoforge/agents/__init__.py +0 -0
  3. autoforge/agents/loop.py +168 -0
  4. autoforge/agents/providers.py +272 -0
  5. autoforge/api.py +330 -0
  6. autoforge/cli/__init__.py +0 -0
  7. autoforge/cli/agent.py +102 -0
  8. autoforge/cli/data.py +42 -0
  9. autoforge/cli/hpo.py +98 -0
  10. autoforge/cli/main.py +112 -0
  11. autoforge/cli/models.py +172 -0
  12. autoforge/cli/output.py +40 -0
  13. autoforge/cli/packs.py +185 -0
  14. autoforge/cli/quickstart.py +61 -0
  15. autoforge/cli/runs.py +233 -0
  16. autoforge/config.py +31 -0
  17. autoforge/data/__init__.py +0 -0
  18. autoforge/data/profile.py +99 -0
  19. autoforge/data/registry.py +47 -0
  20. autoforge/engine/__init__.py +0 -0
  21. autoforge/engine/context.py +125 -0
  22. autoforge/engine/diagnose.py +134 -0
  23. autoforge/engine/hpo.py +355 -0
  24. autoforge/engine/runner.py +380 -0
  25. autoforge/engine/store.py +207 -0
  26. autoforge/engine/validate.py +172 -0
  27. autoforge/engine/worker.py +68 -0
  28. autoforge/errors.py +17 -0
  29. autoforge/examples/__init__.py +0 -0
  30. autoforge/examples/churn.csv +601 -0
  31. autoforge/gui/__init__.py +0 -0
  32. autoforge/gui/app.py +243 -0
  33. autoforge/gui/static/app.js +546 -0
  34. autoforge/gui/static/index.html +26 -0
  35. autoforge/gui/static/style.css +83 -0
  36. autoforge/hardware/__init__.py +0 -0
  37. autoforge/hardware/detect.py +161 -0
  38. autoforge/packs/__init__.py +0 -0
  39. autoforge/packs/authoring.py +203 -0
  40. autoforge/packs/errors.py +42 -0
  41. autoforge/packs/index.py +149 -0
  42. autoforge/packs/installer.py +241 -0
  43. autoforge/packs/manifest.py +192 -0
  44. autoforge/policy/__init__.py +0 -0
  45. autoforge/policy/privacy.py +68 -0
  46. autoforge/registry/__init__.py +0 -0
  47. autoforge/registry/models.py +350 -0
  48. autoforge/schemas/__init__.py +0 -0
  49. autoforge/schemas/core.py +137 -0
  50. autoforge/serve/__init__.py +0 -0
  51. autoforge/serve/mcp_server.py +293 -0
  52. autoforge/serve/model_mcp.py +98 -0
  53. autoforge/serve/rest.py +87 -0
  54. autoforge_trainer-1.0.2.dist-info/METADATA +216 -0
  55. autoforge_trainer-1.0.2.dist-info/RECORD +58 -0
  56. autoforge_trainer-1.0.2.dist-info/WHEEL +4 -0
  57. autoforge_trainer-1.0.2.dist-info/entry_points.txt +2 -0
  58. autoforge_trainer-1.0.2.dist-info/licenses/LICENSE +21 -0
autoforge/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AutoForge: AI that trains AI."""
2
+
3
+ __version__ = "1.0.2"
File without changes
@@ -0,0 +1,168 @@
1
+ """Built-in agent loop (P4b): an LLM drives AutoForge through the same tools
2
+ the MCP server exposes. The tools run in-process via an MCP client, so the
3
+ privacy guard, audit log and error envelopes are identical to P4a.
4
+
5
+ Human approval gate: by default the person confirms before training starts
6
+ (`run_start`, `run_retry`); --yes turns it off. A declined call returns
7
+ USER_DECLINED with the person's feedback so the model can revise the plan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Awaitable, Callable
17
+
18
+ from mcp import Client
19
+
20
+ from autoforge import config
21
+ from autoforge.agents.providers import Provider, ToolCall, ToolResult, ToolSpec
22
+ from autoforge.schemas.core import BrainKind
23
+ from autoforge.serve.mcp_server import INSTRUCTIONS, build_server
24
+
25
+ GATED_TOOLS = {"run_start", "run_retry", "hpo_start"}
26
+ MAX_RESULT_CHARS = 20_000
27
+
28
+ SYSTEM_PROMPT = """\
29
+ You are AutoForge's training agent. You analyse what the user needs, choose a training method that fits
30
+ their data and hardware, train the model on their machine, and report the result.
31
+
32
+ {instructions}
33
+
34
+ How to work:
35
+ - Start with hardware_profile and data_profile. Base every choice on what the tools return.
36
+ - Search packs for the task and read pack_info's knowledge before choosing a recipe; follow its guidance
37
+ (e.g. start with the simple baseline recipe).
38
+ - Validate the plan, start the run, then poll with run_wait until it finishes. If it fails, read run_logs,
39
+ fix the plan and try again. Compare against the baseline before trying anything heavier.
40
+ - If a tool returns USER_DECLINED, read the user's feedback in the message and revise.
41
+ - If a tool returns PRIVACY_DENIED, stop and explain; never look for another way to read that data.
42
+ - After a working baseline, use run_diagnose for problems and hpo_start to tune (trial 0 is the baseline);
43
+ report whether tuning improved the metric.
44
+ - When the user wants to use the model, register the best run with model_register and show a
45
+ model_predict example.
46
+ - Finish with a short report: the method and why, the metrics, the run_id and the model path.
47
+ """
48
+
49
+
50
+ @dataclass
51
+ class AgentEvent:
52
+ kind: str # text | tool_call | tool_result | approval | info | done
53
+ data: dict[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass
57
+ class AgentResult:
58
+ final_text: str
59
+ steps: int
60
+ stop: str # end | refusal | max_steps | error
61
+ runs: list[str]
62
+ transcript: str
63
+
64
+
65
+ ApproveFn = Callable[[ToolCall], Awaitable[tuple[bool, str]]]
66
+ EventFn = Callable[[AgentEvent], None]
67
+
68
+
69
+ def _truncate(text: str) -> str:
70
+ if len(text) <= MAX_RESULT_CHARS:
71
+ return text
72
+ return text[:MAX_RESULT_CHARS] + f"\n... [truncated {len(text) - MAX_RESULT_CHARS} chars]"
73
+
74
+
75
+ class Transcript:
76
+ """JSONL record of the whole agent session, next to the runs it started."""
77
+
78
+ def __init__(self) -> None:
79
+ d = config.home() / "agent-sessions"
80
+ d.mkdir(parents=True, exist_ok=True)
81
+ self.path = d / f"{time.strftime('%Y%m%d-%H%M%S')}.jsonl"
82
+
83
+ def write(self, kind: str, **data: Any) -> None:
84
+ with self.path.open("a", encoding="utf-8") as f:
85
+ f.write(json.dumps({"t": time.time(), "kind": kind, **data}, ensure_ascii=False, default=str) + "\n")
86
+
87
+
88
+ async def run_agent(
89
+ goal: str,
90
+ provider: Provider,
91
+ actor: str,
92
+ brain: BrainKind,
93
+ approve: ApproveFn | None = None,
94
+ on_event: EventFn | None = None,
95
+ max_steps: int = 40,
96
+ data_path: str | None = None,
97
+ ) -> AgentResult:
98
+ emit = on_event or (lambda e: None)
99
+ transcript = Transcript()
100
+ runs: list[str] = []
101
+ server = build_server(actor, log_level="WARNING") # the agent reports tool errors itself
102
+
103
+ user_msg = goal
104
+ if data_path:
105
+ user_msg += f"\n\nData: {Path(data_path).expanduser().resolve()}"
106
+ transcript.write("start", goal=goal, actor=actor, brain=brain.value, data_path=data_path)
107
+
108
+ async with Client(server) as mcp:
109
+ listed = await mcp.list_tools()
110
+ tools = [ToolSpec(t.name, t.description or "", t.input_schema) for t in listed.tools]
111
+ provider.start(SYSTEM_PROMPT.format(instructions=INSTRUCTIONS), tools)
112
+ provider.add_user(user_msg)
113
+
114
+ final_text, stop = "", "max_steps"
115
+ for step in range(1, max_steps + 1):
116
+ turn = await provider.complete()
117
+ if turn.text:
118
+ final_text = turn.text
119
+ emit(AgentEvent("text", {"text": turn.text}))
120
+ transcript.write("text", text=turn.text)
121
+ if turn.stop == "refusal":
122
+ stop = "refusal"
123
+ emit(AgentEvent("info", {"message": f"model declined: {turn.detail or ''}"}))
124
+ break
125
+ if not turn.tool_calls:
126
+ if turn.stop == "pause":
127
+ continue # server paused a long turn; resend to let it continue
128
+ stop = "end"
129
+ break
130
+
131
+ results = []
132
+ for call in turn.tool_calls:
133
+ emit(AgentEvent("tool_call", {"name": call.name, "arguments": call.arguments}))
134
+ transcript.write("tool_call", name=call.name, arguments=call.arguments)
135
+ if call.parse_error:
136
+ content = json.dumps({"ok": False, "error": {"code": "BAD_TOOL_CALL", "message": call.parse_error}})
137
+ results.append(ToolResult(call.id, content, is_error=True))
138
+ continue
139
+ if call.name in GATED_TOOLS and approve is not None:
140
+ ok, feedback = await approve(call)
141
+ emit(AgentEvent("approval", {"name": call.name, "approved": ok, "feedback": feedback}))
142
+ transcript.write("approval", name=call.name, approved=ok, feedback=feedback)
143
+ if not ok:
144
+ msg = "The user declined this action." + (f" Feedback: {feedback}" if feedback else "")
145
+ content = json.dumps({"ok": False, "error": {"code": "USER_DECLINED", "message": msg}})
146
+ results.append(ToolResult(call.id, content, is_error=True))
147
+ continue
148
+ res = await mcp.call_tool(call.name, call.arguments)
149
+ envelope = res.structured_content
150
+ if envelope is None: # unstructured (e.g. argument validation error from the MCP layer)
151
+ text = "\n".join(getattr(c, "text", "") for c in res.content)
152
+ envelope = {"ok": False, "error": {"code": "BAD_TOOL_CALL", "message": text}}
153
+ if call.name in ("run_start", "run_retry") and envelope.get("ok"):
154
+ runs.append(envelope["data"]["run_id"])
155
+ if call.name == "hpo_start" and envelope.get("ok"):
156
+ runs.append(envelope["data"]["study_id"])
157
+ content = _truncate(json.dumps(envelope, ensure_ascii=False, default=str))
158
+ emit(AgentEvent("tool_result", {"name": call.name, "ok": envelope.get("ok"),
159
+ "error": (envelope.get("error") or {}).get("code")}))
160
+ transcript.write("tool_result", name=call.name, ok=envelope.get("ok"), content=content[:2000])
161
+ results.append(ToolResult(call.id, content, is_error=not envelope.get("ok", False)))
162
+ provider.add_tool_results(results)
163
+ else:
164
+ step = max_steps
165
+
166
+ transcript.write("done", stop=stop, steps=step, runs=runs)
167
+ emit(AgentEvent("done", {"stop": stop, "steps": step, "runs": runs}))
168
+ return AgentResult(final_text=final_text, steps=step, stop=stop, runs=runs, transcript=str(transcript.path))
@@ -0,0 +1,272 @@
1
+ """LLM providers for the built-in agent (P4b).
2
+
3
+ Each provider owns its own conversation state, so the agent loop stays
4
+ provider-neutral:
5
+ p.start(system, tools); p.add_user(text)
6
+ turn = await p.complete() # text + tool calls
7
+ p.add_tool_results([...]) # one result per call, all at once
8
+
9
+ Brain specs:
10
+ anthropic:<model> Claude via the official SDK (cloud)
11
+ openai:<model> OpenAI or any cloud OpenAI-compatible API (--base-url)
12
+ ollama:<model> local Ollama (OpenAI-compatible endpoint)
13
+ local:<model> any local OpenAI-compatible server (vLLM, LM Studio, llama.cpp) with --base-url
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from autoforge.errors import AutoForgeError
24
+ from autoforge.schemas.core import BrainKind
25
+
26
+ DEFAULT_BRAIN = "anthropic:claude-opus-5-5"
27
+ # Models that accept server-side refusal fallbacks (fallbacks: "default").
28
+ CLAUDE_FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
29
+
30
+
31
+ class ProviderError(AutoForgeError):
32
+ code = "PROVIDER_ERROR"
33
+
34
+
35
+ @dataclass
36
+ class ToolSpec:
37
+ name: str
38
+ description: str
39
+ input_schema: dict[str, Any]
40
+
41
+
42
+ @dataclass
43
+ class ToolCall:
44
+ id: str
45
+ name: str
46
+ arguments: dict[str, Any]
47
+ parse_error: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class ToolResult:
52
+ call_id: str
53
+ content: str
54
+ is_error: bool = False
55
+
56
+
57
+ @dataclass
58
+ class Turn:
59
+ text: str = ""
60
+ tool_calls: list[ToolCall] = field(default_factory=list)
61
+ stop: str = "end" # end | tool_use | pause | max_tokens | refusal
62
+ detail: str | None = None
63
+
64
+
65
+ @dataclass
66
+ class BrainSpec:
67
+ provider: str
68
+ model: str
69
+ base_url: str | None = None
70
+ api_key_env: str | None = None
71
+
72
+ @property
73
+ def is_local(self) -> bool:
74
+ return self.provider in ("ollama", "local")
75
+
76
+ @property
77
+ def brain_kind(self) -> BrainKind:
78
+ return BrainKind.local if self.is_local else BrainKind.api
79
+
80
+ @property
81
+ def actor(self) -> str:
82
+ # 'ai:local:...' is what the privacy guard recognises as a local model.
83
+ return f"ai:local:{self.provider}:{self.model}" if self.is_local else f"ai:{self.provider}:{self.model}"
84
+
85
+
86
+ def parse_brain(spec: str | None, base_url: str | None = None, api_key_env: str | None = None) -> BrainSpec:
87
+ spec = spec or os.environ.get("AUTOFORGE_BRAIN") or DEFAULT_BRAIN
88
+ provider, sep, model = spec.partition(":")
89
+ if not sep or not model:
90
+ raise ProviderError(f"bad brain '{spec}'", hint="Use provider:model, e.g. anthropic:claude-opus-5-5 or ollama:qwen3")
91
+ if provider not in ("anthropic", "openai", "ollama", "local"):
92
+ raise ProviderError(f"unknown provider '{provider}'", hint="Choose anthropic, openai, ollama or local.")
93
+ if provider == "local" and not base_url:
94
+ raise ProviderError("local:<model> needs --base-url (an OpenAI-compatible endpoint, e.g. http://localhost:8000/v1)")
95
+ return BrainSpec(provider, model, base_url, api_key_env)
96
+
97
+
98
+ def make_provider(spec: BrainSpec) -> "Provider":
99
+ if spec.provider == "anthropic":
100
+ return AnthropicProvider(spec.model)
101
+ if spec.provider == "ollama":
102
+ host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
103
+ if not host.startswith("http"):
104
+ host = "http://" + host
105
+ return OpenAICompatProvider(spec.model, spec.base_url or f"{host}/v1", api_key=None)
106
+ if spec.provider == "local":
107
+ return OpenAICompatProvider(spec.model, spec.base_url, api_key=None)
108
+ key_env = spec.api_key_env or "OPENAI_API_KEY"
109
+ key = os.environ.get(key_env)
110
+ if not key:
111
+ raise ProviderError(f"{key_env} is not set", hint=f"export {key_env}=... (or pass --api-key-env)")
112
+ return OpenAICompatProvider(spec.model, spec.base_url or "https://api.openai.com/v1", api_key=key)
113
+
114
+
115
+ class Provider:
116
+ def start(self, system: str, tools: list[ToolSpec]) -> None: ...
117
+ def add_user(self, text: str) -> None: ...
118
+ async def complete(self) -> Turn: ...
119
+ def add_tool_results(self, results: list[ToolResult]) -> None: ...
120
+
121
+
122
+ # ---------------------------------------------------------------- Claude
123
+
124
+
125
+ class AnthropicProvider(Provider):
126
+ """Claude via the official Anthropic SDK (manual tool-use loop).
127
+
128
+ - Effort 'high' (agentic work); adaptive thinking is the model default.
129
+ - Server-side refusal fallback ('default') on models that support it.
130
+ - The assistant content is appended back unchanged (thinking blocks included),
131
+ so the history stays append-only.
132
+ """
133
+
134
+ def __init__(self, model: str, client: Any = None, effort: str = "high", max_tokens: int = 16000):
135
+ if client is None:
136
+ try:
137
+ import anthropic
138
+ except ImportError:
139
+ raise ProviderError("the anthropic package is not installed", hint='pip install "autoforge[anthropic]"')
140
+ client = anthropic.AsyncAnthropic()
141
+ self.client = client
142
+ self.model = model
143
+ self.effort = effort
144
+ self.max_tokens = max_tokens
145
+ self.messages: list[dict[str, Any]] = []
146
+
147
+ def start(self, system: str, tools: list[ToolSpec]) -> None:
148
+ self.system = system
149
+ self.tools = [{"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in tools]
150
+
151
+ def add_user(self, text: str) -> None:
152
+ self.messages.append({"role": "user", "content": text})
153
+
154
+ async def complete(self) -> Turn:
155
+ kwargs: dict[str, Any] = dict(
156
+ model=self.model,
157
+ max_tokens=self.max_tokens,
158
+ system=self.system,
159
+ tools=self.tools,
160
+ messages=self.messages,
161
+ output_config={"effort": self.effort},
162
+ cache_control={"type": "ephemeral"}, # tools + system + history are a stable prefix
163
+ )
164
+ if self.model in CLAUDE_FALLBACK_MODELS:
165
+ kwargs |= {"betas": ["server-side-fallback-2026-07-01"], "fallbacks": "default"}
166
+ try:
167
+ response = await self.client.beta.messages.create(**kwargs)
168
+ except Exception as e: # SDK errors: auth, rate limit, network, bad request
169
+ raise ProviderError(f"Claude API call failed: {type(e).__name__}: {e}") from e
170
+
171
+ self.messages.append({"role": "assistant", "content": response.content})
172
+ text = "\n".join(b.text for b in response.content if b.type == "text" and b.text)
173
+ calls = [ToolCall(b.id, b.name, dict(b.input or {})) for b in response.content if b.type == "tool_use"]
174
+ stop = {"end_turn": "end", "tool_use": "tool_use", "pause_turn": "pause",
175
+ "max_tokens": "max_tokens", "refusal": "refusal"}.get(response.stop_reason, "end")
176
+ detail = None
177
+ if stop == "refusal" and getattr(response, "stop_details", None):
178
+ detail = f"{response.stop_details.category}: {response.stop_details.explanation}"
179
+ if stop == "max_tokens":
180
+ # A tool_use block cut off by max_tokens may have incomplete input: don't run it,
181
+ # but every tool_use still needs a tool_result, so report it as an error.
182
+ for c in calls:
183
+ c.parse_error = "response hit max_tokens; tool input may be truncated, call the tool again"
184
+ return Turn(text=text, tool_calls=calls, stop=stop, detail=detail)
185
+
186
+ def add_tool_results(self, results: list[ToolResult]) -> None:
187
+ self.messages.append({
188
+ "role": "user",
189
+ "content": [
190
+ {"type": "tool_result", "tool_use_id": r.call_id, "content": r.content, "is_error": r.is_error}
191
+ for r in results
192
+ ],
193
+ })
194
+
195
+
196
+ # ---------------------------------------------------------------- OpenAI-compatible (Ollama, OpenAI, vLLM ...)
197
+
198
+
199
+ def simplify_schema(schema: Any) -> Any:
200
+ """Make JSON Schemas easier for small local models: `X | None` (anyOf with null) becomes X,
201
+ and pydantic's 'title' noise is dropped. Defaults and descriptions are kept."""
202
+ if isinstance(schema, list):
203
+ return [simplify_schema(s) for s in schema]
204
+ if not isinstance(schema, dict):
205
+ return schema
206
+ out = {k: simplify_schema(v) for k, v in schema.items() if k != "title"}
207
+ any_of = out.get("anyOf")
208
+ if isinstance(any_of, list):
209
+ non_null = [s for s in any_of if s != {"type": "null"}]
210
+ if len(non_null) == 1 and len(non_null) < len(any_of):
211
+ merged = {k: v for k, v in out.items() if k != "anyOf"}
212
+ return non_null[0] | merged
213
+ return out
214
+
215
+
216
+ class OpenAICompatProvider(Provider):
217
+ """Chat Completions with function calling over plain HTTP (httpx2)."""
218
+
219
+ def __init__(self, model: str, base_url: str, api_key: str | None, transport: Any = None, timeout: float = 600):
220
+ import httpx2
221
+
222
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
223
+ self.client = httpx2.AsyncClient(base_url=base_url.rstrip("/") + "/", headers=headers,
224
+ timeout=timeout, transport=transport)
225
+ self.model = model
226
+ self.base_url = base_url
227
+ self.messages: list[dict[str, Any]] = []
228
+
229
+ def start(self, system: str, tools: list[ToolSpec]) -> None:
230
+ self.messages = [{"role": "system", "content": system}]
231
+ self.tools = [
232
+ {"type": "function", "function": {"name": t.name, "description": t.description,
233
+ "parameters": simplify_schema(t.input_schema)}}
234
+ for t in tools
235
+ ]
236
+
237
+ def add_user(self, text: str) -> None:
238
+ self.messages.append({"role": "user", "content": text})
239
+
240
+ async def complete(self) -> Turn:
241
+ import httpx2
242
+
243
+ body = {"model": self.model, "messages": self.messages, "tools": self.tools}
244
+ try:
245
+ r = await self.client.post("chat/completions", json=body)
246
+ r.raise_for_status()
247
+ data = r.json()
248
+ except httpx2.HTTPStatusError as e:
249
+ raise ProviderError(f"{self.base_url} returned {e.response.status_code}: {e.response.text[:500]}") from e
250
+ except httpx2.RequestError as e:
251
+ raise ProviderError(f"cannot reach {self.base_url}: {e}",
252
+ hint="Is the model server running? For Ollama: `ollama serve` and `ollama pull <model>`.") from e
253
+ choice = data["choices"][0]
254
+ msg = choice["message"]
255
+ self.messages.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls") and v is not None})
256
+ calls = []
257
+ for i, tc in enumerate(msg.get("tool_calls") or []):
258
+ fn = tc.get("function", {})
259
+ raw = fn.get("arguments") or "{}"
260
+ try:
261
+ args = raw if isinstance(raw, dict) else json.loads(raw)
262
+ err = None
263
+ except json.JSONDecodeError as e:
264
+ args, err = {}, f"arguments are not valid JSON: {e}"
265
+ calls.append(ToolCall(tc.get("id") or f"call_{i}", fn.get("name", ""), args, err))
266
+ finish = choice.get("finish_reason")
267
+ stop = "tool_use" if calls else ("max_tokens" if finish == "length" else "end")
268
+ return Turn(text=msg.get("content") or "", tool_calls=calls, stop=stop)
269
+
270
+ def add_tool_results(self, results: list[ToolResult]) -> None:
271
+ for r in results:
272
+ self.messages.append({"role": "tool", "tool_call_id": r.call_id, "content": r.content})