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.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|