millforge 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.
Files changed (116) hide show
  1. millforge/__init__.py +1174 -0
  2. millforge/_forge/LICENSE +21 -0
  3. millforge/_forge/PROVENANCE.json +295 -0
  4. millforge/_forge/UPDATE_POLICY.md +24 -0
  5. millforge/_forge/__init__.py +14 -0
  6. millforge/_forge/adapter.py +2232 -0
  7. millforge/_forge/base_runner.py +121 -0
  8. millforge/_forge/clients/__init__.py +10 -0
  9. millforge/_forge/clients/base.py +200 -0
  10. millforge/_forge/context/__init__.py +23 -0
  11. millforge/_forge/context/manager.py +178 -0
  12. millforge/_forge/context/strategies.py +335 -0
  13. millforge/_forge/core/__init__.py +16 -0
  14. millforge/_forge/core/inference.py +433 -0
  15. millforge/_forge/core/messages.py +119 -0
  16. millforge/_forge/core/runner.py +479 -0
  17. millforge/_forge/core/steps.py +108 -0
  18. millforge/_forge/core/workflow.py +400 -0
  19. millforge/_forge/errors.py +222 -0
  20. millforge/_forge/guardrails/__init__.py +21 -0
  21. millforge/_forge/guardrails/error_tracker.py +71 -0
  22. millforge/_forge/guardrails/guardrails.py +194 -0
  23. millforge/_forge/guardrails/nudge.py +47 -0
  24. millforge/_forge/guardrails/response_validator.py +119 -0
  25. millforge/_forge/guardrails/step_enforcer.py +183 -0
  26. millforge/_forge/prompts/__init__.py +16 -0
  27. millforge/_forge/prompts/nudges.py +95 -0
  28. millforge/_forge/prompts/templates.py +285 -0
  29. millforge/_version.py +3 -0
  30. millforge/artifacts.py +570 -0
  31. millforge/base/__init__.py +97 -0
  32. millforge/base/composition.py +402 -0
  33. millforge/base/context.py +285 -0
  34. millforge/base/harness.py +138 -0
  35. millforge/base/identity.py +465 -0
  36. millforge/base/options.py +34 -0
  37. millforge/base/platform.py +17 -0
  38. millforge/base/prompt.py +317 -0
  39. millforge/base/runner.py +546 -0
  40. millforge/compiled_plan.py +970 -0
  41. millforge/compiler/__init__.py +231 -0
  42. millforge/compiler/artifact_validation.py +257 -0
  43. millforge/compiler/canonicalization.py +169 -0
  44. millforge/compiler/capabilities.py +66 -0
  45. millforge/compiler/catalogs.py +500 -0
  46. millforge/compiler/diagnostics.py +491 -0
  47. millforge/compiler/graph.py +678 -0
  48. millforge/compiler/lowering.py +198 -0
  49. millforge/compiler/output.py +692 -0
  50. millforge/compiler/parsing.py +1424 -0
  51. millforge/compiler/requests.py +1180 -0
  52. millforge/compiler/schema_validation.py +272 -0
  53. millforge/compiler/semantic.py +490 -0
  54. millforge/compiler/service.py +448 -0
  55. millforge/compiler/source.py +375 -0
  56. millforge/compiler/validators.py +184 -0
  57. millforge/connectors/__init__.py +95 -0
  58. millforge/connectors/admission.py +801 -0
  59. millforge/connectors/broker.py +202 -0
  60. millforge/connectors/contracts.py +1159 -0
  61. millforge/connectors/diagnostics.py +189 -0
  62. millforge/connectors/fake.py +66 -0
  63. millforge/connectors/runtime.py +236 -0
  64. millforge/contracts.py +2860 -0
  65. millforge/custom_tools/__init__.py +67 -0
  66. millforge/custom_tools/compiler.py +724 -0
  67. millforge/custom_tools/contracts.py +1093 -0
  68. millforge/custom_tools/diagnostics.py +205 -0
  69. millforge/eval_artifacts.py +952 -0
  70. millforge/eval_boundary.py +2435 -0
  71. millforge/eval_fixtures/__init__.py +1 -0
  72. millforge/eval_fixtures/default_pack/__init__.py +1 -0
  73. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
  74. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
  75. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
  76. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
  77. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
  78. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
  79. millforge/eval_fixtures/default_pack/manifest.json +12 -0
  80. millforge/eval_modes.py +1282 -0
  81. millforge/eval_presets.py +1398 -0
  82. millforge/eval_reports.py +2517 -0
  83. millforge/eval_suite.py +2429 -0
  84. millforge/eval_trials.py +2632 -0
  85. millforge/eval_workflow.py +794 -0
  86. millforge/exceptions.py +122 -0
  87. millforge/model_backend.py +2098 -0
  88. millforge/protocols.py +340 -0
  89. millforge/py.typed +0 -0
  90. millforge/runtime.py +1791 -0
  91. millforge/testing/__init__.py +1089 -0
  92. millforge/tools/__init__.py +83 -0
  93. millforge/tools/builtin_runtime.py +1339 -0
  94. millforge/tools/builtins.py +773 -0
  95. millforge/tools/execution.py +1545 -0
  96. millforge/tools/path_policy.py +155 -0
  97. millforge/tools/pi_compat/PI_LICENSE +21 -0
  98. millforge/tools/pi_compat/PROVENANCE.json +55 -0
  99. millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
  100. millforge/tools/pi_compat/__init__.py +34 -0
  101. millforge/tools/pi_compat/contracts.py +49 -0
  102. millforge/tools/pi_compat/editing.py +390 -0
  103. millforge/tools/pi_compat/mutations.py +57 -0
  104. millforge/tools/pi_compat/operations.py +401 -0
  105. millforge/tools/pi_compat/paths.py +155 -0
  106. millforge/tools/pi_compat/process.py +1375 -0
  107. millforge/tools/pi_compat/search.py +738 -0
  108. millforge/tools/pi_compat/truncation.py +267 -0
  109. millforge/tools/pi_compat_catalog.py +396 -0
  110. millforge/tools/pi_compat_runtime.py +460 -0
  111. millforge/tools/registry.py +553 -0
  112. millforge/tools/results.py +533 -0
  113. millforge-0.1.0.dist-info/METADATA +844 -0
  114. millforge-0.1.0.dist-info/RECORD +116 -0
  115. millforge-0.1.0.dist-info/WHEEL +4 -0
  116. millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,400 @@
1
+ """Tool and workflow definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, TypeAlias
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, create_model
10
+
11
+
12
+ _SCHEMA_OBJECT_KEYS = frozenset(
13
+ {"type", "properties", "required", "description", "additionalProperties"}
14
+ )
15
+ _SCHEMA_PROPERTY_KEYS = frozenset(
16
+ {
17
+ "type",
18
+ "const",
19
+ "properties",
20
+ "required",
21
+ "description",
22
+ "default",
23
+ "enum",
24
+ "items",
25
+ "additionalProperties",
26
+ }
27
+ )
28
+ _SUPPORTED_JSON_TYPES = frozenset(
29
+ {"string", "integer", "number", "boolean", "object", "array"}
30
+ )
31
+
32
+
33
+ def _to_pascal(name: str) -> str:
34
+ """Convert snake_case tool name to PascalCaseParams."""
35
+ return "".join(part.capitalize() for part in name.split("_")) + "Params"
36
+
37
+
38
+ def _reject_unsupported_keys(
39
+ schema: dict[str, Any],
40
+ *,
41
+ allowed: frozenset[str],
42
+ location: str,
43
+ ) -> None:
44
+ unsupported = sorted(set(schema) - allowed)
45
+ if unsupported:
46
+ raise ValueError(
47
+ f"Unsupported JSON Schema feature(s) at {location}: "
48
+ f"{', '.join(unsupported)}"
49
+ )
50
+
51
+
52
+ def _require_object(value: Any, *, location: str) -> dict[str, Any]:
53
+ if not isinstance(value, dict):
54
+ raise ValueError(f"Expected object at {location}")
55
+ return value
56
+
57
+
58
+ def _validate_additional_properties(schema: dict[str, Any], *, location: str) -> None:
59
+ if "additionalProperties" not in schema:
60
+ raise ValueError(
61
+ f"Missing required JSON Schema key at {location}: additionalProperties"
62
+ )
63
+ if schema["additionalProperties"] is not False:
64
+ raise ValueError(
65
+ "Unsupported JSON Schema feature at "
66
+ f"{location}: additionalProperties must be false"
67
+ )
68
+
69
+
70
+ def _validate_required(
71
+ required: Any,
72
+ properties: dict[str, Any],
73
+ *,
74
+ location: str,
75
+ ) -> set[str]:
76
+ if required is None:
77
+ raise ValueError(f"Missing required JSON Schema key at {location}: required")
78
+ if not isinstance(required, list) or not all(
79
+ isinstance(item, str) for item in required
80
+ ):
81
+ raise ValueError(f"Expected string list for required at {location}")
82
+ unknown = sorted(set(required) - set(properties))
83
+ if unknown:
84
+ raise ValueError(
85
+ f"Required field(s) not present in properties at {location}: "
86
+ f"{', '.join(unknown)}"
87
+ )
88
+ return set(required)
89
+
90
+
91
+ def _json_schema_to_type(
92
+ prop: dict[str, Any],
93
+ field_name: str,
94
+ model_name_prefix: str,
95
+ ) -> Any:
96
+ """Convert a single JSON Schema property dict to a Python type.
97
+
98
+ Handles primitives, enums, nested objects, and arrays recursively.
99
+ """
100
+ _reject_unsupported_keys(
101
+ prop,
102
+ allowed=_SCHEMA_PROPERTY_KEYS,
103
+ location=f"{model_name_prefix}.{field_name}",
104
+ )
105
+ if "const" in prop:
106
+ value = prop["const"]
107
+ if not isinstance(value, str | int | float | bool):
108
+ raise ValueError(
109
+ f"Unsupported JSON Schema const at {model_name_prefix}.{field_name}"
110
+ )
111
+ return Literal[value] # type: ignore[valid-type]
112
+
113
+ if "type" not in prop:
114
+ raise ValueError(
115
+ f"Missing JSON Schema type at {model_name_prefix}.{field_name}"
116
+ )
117
+
118
+ json_type_value = prop["type"]
119
+ if not isinstance(json_type_value, str):
120
+ raise ValueError(
121
+ f"Unsupported JSON Schema type at {model_name_prefix}.{field_name}"
122
+ )
123
+ if json_type_value not in _SUPPORTED_JSON_TYPES:
124
+ raise ValueError(
125
+ f"Unsupported JSON Schema type at {model_name_prefix}.{field_name}: "
126
+ f"{json_type_value}"
127
+ )
128
+
129
+ # Enum takes priority — Literal type
130
+ if "enum" in prop:
131
+ enum_values = prop["enum"]
132
+ if not isinstance(enum_values, list) or not enum_values:
133
+ raise ValueError(
134
+ f"Expected non-empty enum list at {model_name_prefix}.{field_name}"
135
+ )
136
+ values = tuple(enum_values)
137
+ return Literal[values] # type: ignore[valid-type]
138
+
139
+ type_map: dict[str, type] = {
140
+ "string": str,
141
+ "integer": int,
142
+ "number": float,
143
+ "boolean": bool,
144
+ }
145
+
146
+ if json_type_value in type_map:
147
+ return type_map[json_type_value]
148
+
149
+ if json_type_value == "object":
150
+ _validate_additional_properties(
151
+ prop,
152
+ location=f"{model_name_prefix}.{field_name}",
153
+ )
154
+ if "properties" not in prop:
155
+ raise ValueError(
156
+ "Missing required JSON Schema key at "
157
+ f"{model_name_prefix}.{field_name}: properties"
158
+ )
159
+ sub_props = _require_object(
160
+ prop["properties"],
161
+ location=f"{model_name_prefix}.{field_name}.properties",
162
+ )
163
+ sub_required = _validate_required(
164
+ prop.get("required"),
165
+ sub_props,
166
+ location=f"{model_name_prefix}.{field_name}",
167
+ )
168
+ return _build_model(
169
+ sub_props,
170
+ sub_required,
171
+ f"{model_name_prefix}_{field_name.capitalize()}",
172
+ )
173
+
174
+ if json_type_value == "array":
175
+ if "items" not in prop:
176
+ raise ValueError(
177
+ f"Array schema missing items at {model_name_prefix}.{field_name}"
178
+ )
179
+ items = _require_object(
180
+ prop["items"],
181
+ location=f"{model_name_prefix}.{field_name}.items",
182
+ )
183
+ item_type = _json_schema_to_type(items, field_name + "Item", model_name_prefix)
184
+ return list[item_type] # type: ignore[valid-type]
185
+
186
+ raise AssertionError(f"unreachable JSON Schema type: {json_type_value}")
187
+
188
+
189
+ def _build_model(
190
+ properties: dict[str, Any],
191
+ required: set[str],
192
+ model_name: str,
193
+ ) -> type[BaseModel]:
194
+ """Build a dynamic Pydantic model from JSON Schema properties."""
195
+ fields: dict[str, Any] = {}
196
+
197
+ for fname, fprop in properties.items():
198
+ if not isinstance(fname, str):
199
+ raise ValueError(f"Unsupported non-string JSON Schema property: {fname!r}")
200
+ fprop = _require_object(fprop, location=f"{model_name}.{fname}")
201
+ python_type = _json_schema_to_type(fprop, fname, model_name)
202
+ description = fprop.get("description")
203
+ default = fprop.get("default")
204
+
205
+ if fname in required:
206
+ if description is not None:
207
+ fields[fname] = (python_type, Field(description=description))
208
+ else:
209
+ fields[fname] = (python_type, ...)
210
+ else:
211
+ # Optional field
212
+ if default is not None:
213
+ if description is not None:
214
+ fields[fname] = (
215
+ python_type | None,
216
+ Field(default=default, description=description),
217
+ )
218
+ else:
219
+ fields[fname] = (python_type | None, Field(default=default))
220
+ else:
221
+ if description is not None:
222
+ fields[fname] = (
223
+ python_type | None,
224
+ Field(default=None, description=description),
225
+ )
226
+ else:
227
+ fields[fname] = (python_type | None, None)
228
+
229
+ return create_model(
230
+ model_name,
231
+ __config__=ConfigDict(extra="forbid"),
232
+ **fields,
233
+ ) # type: ignore[call-overload]
234
+
235
+
236
+ class ToolSpec(BaseModel):
237
+ """Declarative tool schema — what the LLM sees."""
238
+
239
+ model_config = ConfigDict(arbitrary_types_allowed=True)
240
+
241
+ name: str
242
+ description: str
243
+ parameters: type[BaseModel]
244
+
245
+ @classmethod
246
+ def from_json_schema(
247
+ cls,
248
+ name: str,
249
+ description: str,
250
+ schema: dict[str, Any],
251
+ ) -> ToolSpec:
252
+ """Create a ToolSpec from a raw JSON Schema dict.
253
+
254
+ The *schema* argument is the ``parameters`` object from an OpenAI-style
255
+ tool definition (i.e. a JSON Schema with ``properties``, ``required``,
256
+ etc.).
257
+ """
258
+ _reject_unsupported_keys(
259
+ schema,
260
+ allowed=_SCHEMA_OBJECT_KEYS,
261
+ location=f"{name} parameters",
262
+ )
263
+ _validate_additional_properties(schema, location=f"{name} parameters")
264
+ if schema.get("type") != "object":
265
+ raise ValueError("Tool parameter schema must be a JSON object")
266
+ if "properties" not in schema:
267
+ raise ValueError(
268
+ f"Missing required JSON Schema key at {name} parameters: properties"
269
+ )
270
+ properties = _require_object(
271
+ schema["properties"],
272
+ location=f"{name} parameters.properties",
273
+ )
274
+ required = _validate_required(
275
+ schema.get("required"),
276
+ properties,
277
+ location=f"{name} parameters",
278
+ )
279
+ model_name = _to_pascal(name)
280
+ params_cls = _build_model(properties, required, model_name)
281
+ return cls(name=name, description=description, parameters=params_cls)
282
+
283
+ def get_json_schema(self) -> dict[str, Any]:
284
+ """Return JSON Schema dict for this tool's parameters."""
285
+ return self.parameters.model_json_schema()
286
+
287
+
288
+ @dataclass
289
+ class ToolDef:
290
+ """Binds a tool schema to its implementation.
291
+
292
+ Downstream projects define tools as ToolDefs. The Workflow holds these
293
+ in a dict keyed by name, deriving the spec list (for the LLM) and
294
+ callable lookup (for execution) internally.
295
+
296
+ Prerequisites express conditional dependencies: "if you call this tool,
297
+ you must have called tool X first." Entries can be:
298
+ - str: name-only ("read_file" — any prior call to read_file satisfies it)
299
+ - dict: arg-matched ({"tool": "read_file", "match_arg": "path"} — a prior
300
+ call to read_file with the same ``path`` value satisfies it)
301
+ - dict: mapped arg-matched
302
+ ({"tool": "lookup", "prerequisite_arg": "source", "current_arg": "path"} —
303
+ a prior lookup ``source`` value must equal the current ``path`` value)
304
+ """
305
+
306
+ spec: ToolSpec
307
+ callable: Callable[..., Any]
308
+ prerequisites: list[str | dict[str, str]] = field(default_factory=list)
309
+
310
+ @property
311
+ def name(self) -> str:
312
+ return self.spec.name
313
+
314
+
315
+ @dataclass
316
+ class ToolCall:
317
+ """Tool invocation returned by an LLMClient.
318
+
319
+ ``args`` is *not* validated at construction. ResponseValidator enforces
320
+ args-shape (must be a dict) before the call reaches downstream stages
321
+ that read into it. Treating args-shape uniformly with other validator
322
+ checks (unknown tool name) lets a malformed call ride the canonical
323
+ tool-error channel instead of crashing the parser.
324
+ """
325
+
326
+ tool: str
327
+ args: Any # may be a non-dict when malformed; ResponseValidator rejects shape
328
+ reasoning: str | None = None
329
+ call_id: str | None = None
330
+ reasoning_content: str | None = field(default=None, repr=False)
331
+
332
+
333
+ @dataclass
334
+ class TextResponse:
335
+ """Non-tool-call response from the model (reasoning trace, refusal, etc.)."""
336
+
337
+ content: str
338
+
339
+
340
+ LLMResponse: TypeAlias = list[ToolCall] | TextResponse
341
+
342
+
343
+ @dataclass
344
+ class Workflow:
345
+ """Declarative workflow definition. Provided by downstream projects.
346
+
347
+ The Workflow holds ToolDefs in an ordered dict keyed by tool name.
348
+ Keys must match ToolDef.spec.name — validated at construction time.
349
+ It does NOT contain execution logic — that's the WorkflowRunner's job.
350
+ """
351
+
352
+ name: str
353
+ description: str
354
+ tools: dict[str, ToolDef]
355
+ required_steps: list[str]
356
+ terminal_tool: str | list[str]
357
+ system_prompt_template: str
358
+ terminal_tools: frozenset[str] = field(default_factory=frozenset, init=False)
359
+
360
+ def __post_init__(self) -> None:
361
+ # Normalize terminal_tool to frozenset for O(1) membership checks.
362
+ if isinstance(self.terminal_tool, str):
363
+ self.terminal_tools = frozenset([self.terminal_tool])
364
+ else:
365
+ self.terminal_tools = frozenset(self.terminal_tool)
366
+
367
+ for key, tool_def in self.tools.items():
368
+ if key != tool_def.name:
369
+ raise ValueError(
370
+ f"Tool key '{key}' does not match ToolDef name '{tool_def.name}'"
371
+ )
372
+ tool_names = set(self.tools.keys())
373
+ for step in self.required_steps:
374
+ if step not in tool_names:
375
+ raise ValueError(f"Required step '{step}' not in tools: {tool_names}")
376
+ for tt in self.terminal_tools:
377
+ if tt not in tool_names:
378
+ raise ValueError(f"Terminal tool '{tt}' not in tools: {tool_names}")
379
+ if tt in self.required_steps:
380
+ raise ValueError(f"Terminal tool '{tt}' cannot also be a required step")
381
+ for key, tool_def in self.tools.items():
382
+ for prereq in tool_def.prerequisites:
383
+ prereq_name = prereq if isinstance(prereq, str) else prereq["tool"]
384
+ if prereq_name not in tool_names:
385
+ raise ValueError(
386
+ f"Prerequisite '{prereq_name}' for tool '{key}' "
387
+ f"not in tools: {tool_names}"
388
+ )
389
+
390
+ def build_system_prompt(self, **kwargs: str) -> str:
391
+ """Render the system prompt with user-provided values."""
392
+ return self.system_prompt_template.format(**kwargs)
393
+
394
+ def get_tool_specs(self) -> list[ToolSpec]:
395
+ """Return all tool specs for passing to the LLM client."""
396
+ return [t.spec for t in self.tools.values()]
397
+
398
+ def get_callable(self, tool_name: str) -> Callable[..., Any]:
399
+ """Return the callable for a tool by name. Raises KeyError if not found."""
400
+ return self.tools[tool_name].callable
@@ -0,0 +1,222 @@
1
+ """Exception hierarchy for the forge library."""
2
+
3
+
4
+ class ForgeError(Exception):
5
+ """Base exception for the forge library."""
6
+
7
+ pass
8
+
9
+
10
+ class UnsupportedModelError(ForgeError):
11
+ """Caller opted into recommended sampling for a model not in the map.
12
+
13
+ Raised by ``apply_sampling_defaults(model, strict=True)`` when ``model``
14
+ has no entry in ``MODEL_SAMPLING_DEFAULTS``. Failing loud is intentional:
15
+ ``recommended_sampling=True`` declares "I want the per-card sampling
16
+ profile for this model"; falling through to backend defaults silently
17
+ would defeat that intent.
18
+ """
19
+
20
+ def __init__(self, model: str):
21
+ super().__init__(
22
+ f"No recommended sampling defaults registered for model {model!r}. "
23
+ f"Either add an entry to MODEL_SAMPLING_DEFAULTS (with HF card URL) "
24
+ f"or drop recommended_sampling=True."
25
+ )
26
+ self.model = model
27
+
28
+
29
+ class ToolCallError(ForgeError):
30
+ """LLM failed to produce a valid tool call after retries."""
31
+
32
+ def __init__(
33
+ self,
34
+ message: str,
35
+ raw_response: str | None = None,
36
+ cause: Exception | None = None,
37
+ ):
38
+ super().__init__(message)
39
+ self.raw_response = raw_response
40
+ self.cause = cause
41
+
42
+
43
+ class ToolExecutionError(ForgeError):
44
+ """A tool callable raised during execution."""
45
+
46
+ def __init__(self, tool_name: str, cause: Exception):
47
+ super().__init__(f"Tool '{tool_name}' raised: {cause}")
48
+ self.tool_name = tool_name
49
+ self.cause = cause
50
+
51
+
52
+ class NonRetryableToolError(Exception):
53
+ """Tool failure that must bypass model self-correction retries.
54
+
55
+ Raise this from a tool callable when the adapter should translate the
56
+ outcome directly instead of feeding a generic tool error back to the model.
57
+ """
58
+
59
+
60
+ class ToolResolutionError(Exception):
61
+ """Tool arguments were valid but the data didn't resolve.
62
+
63
+ The tool equivalent of HTTP 4xx — the call was well-formed and the
64
+ schema was satisfied, but the arguments couldn't be resolved against
65
+ the underlying data (wrong key, empty result set, unrecognized ID,
66
+ etc.).
67
+
68
+ Raise this from a tool callable to signal "try again with different
69
+ arguments" without counting toward consecutive_tool_errors. The
70
+ runner feeds the message back to the model and does NOT mark the
71
+ step as completed.
72
+
73
+ Not a ForgeError — this is a tool-author exception, not a framework
74
+ error. The runner catches it explicitly.
75
+ """
76
+
77
+ def __init__(self, message: str, tool_name: str | None = None):
78
+ super().__init__(message)
79
+ self.tool_name = tool_name
80
+
81
+
82
+ class WorkflowCancelledError(ForgeError):
83
+ """Workflow was cancelled via cancel_event before completion."""
84
+
85
+ def __init__(
86
+ self,
87
+ messages: list,
88
+ completed_steps: dict[str, None],
89
+ iteration: int,
90
+ ):
91
+ super().__init__(
92
+ f"Workflow cancelled at iteration {iteration}. "
93
+ f"Completed steps: {completed_steps}"
94
+ )
95
+ self.messages = messages
96
+ self.completed_steps = completed_steps
97
+ self.iteration = iteration
98
+
99
+
100
+ class MaxIterationsError(ForgeError):
101
+ """Workflow exceeded max_iterations without calling the terminal tool."""
102
+
103
+ def __init__(
104
+ self,
105
+ iterations: int,
106
+ completed_steps: dict[str, None],
107
+ pending_steps: list[str],
108
+ ):
109
+ super().__init__(
110
+ f"Max iterations ({iterations}) exceeded. "
111
+ f"Completed: {completed_steps}, Pending: {pending_steps}"
112
+ )
113
+ self.iterations = iterations
114
+ self.completed_steps = completed_steps
115
+ self.pending_steps = pending_steps
116
+
117
+
118
+ class StepEnforcementError(ForgeError):
119
+ """Model repeatedly tried to call the terminal tool before completing required steps."""
120
+
121
+ def __init__(
122
+ self,
123
+ terminal_tool: str,
124
+ attempts: int,
125
+ pending_steps: list[str],
126
+ ):
127
+ super().__init__(
128
+ f"Model called '{terminal_tool}' prematurely {attempts} times "
129
+ f"without completing required steps: {pending_steps}"
130
+ )
131
+ self.terminal_tool = terminal_tool
132
+ self.attempts = attempts
133
+ self.pending_steps = pending_steps
134
+
135
+
136
+ class PrerequisiteError(ForgeError):
137
+ """Model repeatedly called a tool without satisfying its prerequisites."""
138
+
139
+ def __init__(
140
+ self,
141
+ tool_name: str,
142
+ violations: int,
143
+ missing_prereqs: list[str],
144
+ ):
145
+ super().__init__(
146
+ f"Tool '{tool_name}' called {violations} times "
147
+ f"without satisfying prerequisites: {missing_prereqs}"
148
+ )
149
+ self.tool_name = tool_name
150
+ self.violations = violations
151
+ self.missing_prereqs = missing_prereqs
152
+
153
+
154
+ class ContextBudgetExceeded(ForgeError):
155
+ """Context exceeded budget even after compaction. Unrecoverable."""
156
+
157
+ def __init__(self, estimated_tokens: int, budget_tokens: int):
158
+ super().__init__(
159
+ f"Context budget exceeded: {estimated_tokens} tokens "
160
+ f"estimated, budget is {budget_tokens}"
161
+ )
162
+ self.estimated_tokens = estimated_tokens
163
+ self.budget_tokens = budget_tokens
164
+
165
+
166
+ class HardwareDetectionError(ForgeError):
167
+ """nvidia-smi responded but output couldn't be parsed."""
168
+
169
+ def __init__(self, cause: Exception):
170
+ super().__init__(f"Hardware detection failed: {cause}")
171
+ self.cause = cause
172
+
173
+
174
+ class ContextDiscoveryError(ForgeError):
175
+ """Backend context length response couldn't be parsed."""
176
+
177
+ def __init__(self, cause: Exception):
178
+ super().__init__(f"Context discovery failed: {cause}")
179
+ self.cause = cause
180
+
181
+
182
+ class BudgetResolutionError(ForgeError):
183
+ """No context budget could be determined from any source."""
184
+
185
+ def __init__(self, cause: Exception | None = None) -> None:
186
+ if cause is not None:
187
+ super().__init__(f"Budget resolution failed: {cause}")
188
+ self.__cause__ = cause
189
+ else:
190
+ super().__init__(
191
+ "No context budget could be determined: "
192
+ "no GPU detected and no explicit budget_tokens provided"
193
+ )
194
+
195
+
196
+ class BackendError(ForgeError):
197
+ """Unexpected HTTP error from the LLM backend."""
198
+
199
+ def __init__(self, status_code: int, body: str):
200
+ super().__init__(f"Backend returned {status_code}: {body}")
201
+ self.status_code = status_code
202
+ self.body = body
203
+
204
+
205
+ class ThinkingNotSupportedError(BackendError):
206
+ """Model does not support thinking mode, but think=True was explicitly requested."""
207
+
208
+ def __init__(self, model: str, status_code: int = 400, body: str = ""):
209
+ super().__init__(status_code, body)
210
+ self.model = model
211
+ # Override the generic message with a helpful one
212
+ self.args = (
213
+ f"Model '{model}' does not support thinking. "
214
+ f"Use --think auto or --think false instead.",
215
+ )
216
+
217
+
218
+ class StreamError(ForgeError):
219
+ """Stream ended without producing a FINAL chunk."""
220
+
221
+ def __init__(self, message: str = "Stream ended without FINAL chunk"):
222
+ super().__init__(message)
@@ -0,0 +1,21 @@
1
+ """Private Forge guardrail middleware subset."""
2
+
3
+ from millforge._forge.guardrails.error_tracker import ErrorTracker
4
+ from millforge._forge.guardrails.guardrails import CheckResult, Guardrails
5
+ from millforge._forge.guardrails.nudge import Nudge
6
+ from millforge._forge.guardrails.response_validator import (
7
+ ResponseValidator,
8
+ ValidationResult,
9
+ )
10
+ from millforge._forge.guardrails.step_enforcer import StepCheck, StepEnforcer
11
+
12
+ __all__ = [
13
+ "CheckResult",
14
+ "ErrorTracker",
15
+ "Guardrails",
16
+ "Nudge",
17
+ "ResponseValidator",
18
+ "StepCheck",
19
+ "StepEnforcer",
20
+ "ValidationResult",
21
+ ]