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,267 @@
1
+ """Pi-derived text truncation primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ __all__ = [
9
+ "DEFAULT_MAX_BYTES",
10
+ "DEFAULT_MAX_LINES",
11
+ "GREP_MAX_LINE_LENGTH",
12
+ "TruncationResult",
13
+ "format_size",
14
+ "sanitize_text",
15
+ "truncate_head",
16
+ "truncate_line",
17
+ "truncate_tail",
18
+ ]
19
+
20
+ DEFAULT_MAX_LINES = 2_000
21
+ DEFAULT_MAX_BYTES = 50 * 1024
22
+ GREP_MAX_LINE_LENGTH = 500
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TruncationResult:
27
+ """The Pi-compatible accounting for a text truncation operation."""
28
+
29
+ content: str
30
+ truncated: bool
31
+ truncated_by: Literal["lines", "bytes"] | None
32
+ total_lines: int
33
+ total_bytes: int
34
+ output_lines: int
35
+ output_bytes: int
36
+ last_line_partial: bool
37
+ first_line_exceeds_limit: bool
38
+ max_lines: int
39
+ max_bytes: int
40
+
41
+
42
+ def _utf8_bytes(text: str) -> int:
43
+ return len(text.encode("utf-8"))
44
+
45
+
46
+ def sanitize_text(text: str) -> str:
47
+ """Replace lone surrogate code points before text reaches a model result."""
48
+
49
+ if not any(0xD800 <= ord(character) <= 0xDFFF for character in text):
50
+ return text
51
+ return "".join(
52
+ "\ufffd" if 0xD800 <= ord(character) <= 0xDFFF else character
53
+ for character in text
54
+ )
55
+
56
+
57
+ def _split_lines_for_counting(content: str) -> list[str]:
58
+ if not content:
59
+ return []
60
+ lines = content.split("\n")
61
+ if content.endswith("\n"):
62
+ lines.pop()
63
+ return lines
64
+
65
+
66
+ def format_size(byte_count: int) -> str:
67
+ """Format a byte count with the wording used by Pi tool notices."""
68
+
69
+ if byte_count < 1024:
70
+ return f"{byte_count}B"
71
+ if byte_count < 1024 * 1024:
72
+ return f"{_round_to_fixed_one(byte_count, 1024)}KB"
73
+ return f"{_round_to_fixed_one(byte_count, 1024 * 1024)}MB"
74
+
75
+
76
+ def _round_to_fixed_one(numerator: int, denominator: int) -> str:
77
+ """Render a non-negative binary-size ratio like JavaScript ``toFixed(1)``."""
78
+
79
+ tenths = (numerator * 10 + denominator // 2) // denominator
80
+ return f"{tenths // 10}.{tenths % 10}"
81
+
82
+
83
+ def _unchanged_result(
84
+ content: str,
85
+ *,
86
+ total_lines: int,
87
+ total_bytes: int,
88
+ max_lines: int,
89
+ max_bytes: int,
90
+ ) -> TruncationResult:
91
+ return TruncationResult(
92
+ content=content,
93
+ truncated=False,
94
+ truncated_by=None,
95
+ total_lines=total_lines,
96
+ total_bytes=total_bytes,
97
+ output_lines=total_lines,
98
+ output_bytes=total_bytes,
99
+ last_line_partial=False,
100
+ first_line_exceeds_limit=False,
101
+ max_lines=max_lines,
102
+ max_bytes=max_bytes,
103
+ )
104
+
105
+
106
+ def truncate_head(
107
+ content: str,
108
+ *,
109
+ max_lines: int = DEFAULT_MAX_LINES,
110
+ max_bytes: int = DEFAULT_MAX_BYTES,
111
+ ) -> TruncationResult:
112
+ """Keep Pi's first complete lines under independent line and byte limits."""
113
+
114
+ content = sanitize_text(content)
115
+ total_bytes = _utf8_bytes(content)
116
+ lines = _split_lines_for_counting(content)
117
+ total_lines = len(lines)
118
+ if total_lines <= max_lines and total_bytes <= max_bytes:
119
+ return _unchanged_result(
120
+ content,
121
+ total_lines=total_lines,
122
+ total_bytes=total_bytes,
123
+ max_lines=max_lines,
124
+ max_bytes=max_bytes,
125
+ )
126
+
127
+ if _utf8_bytes(lines[0]) > max_bytes:
128
+ return TruncationResult(
129
+ content="",
130
+ truncated=True,
131
+ truncated_by="bytes",
132
+ total_lines=total_lines,
133
+ total_bytes=total_bytes,
134
+ output_lines=0,
135
+ output_bytes=0,
136
+ last_line_partial=False,
137
+ first_line_exceeds_limit=True,
138
+ max_lines=max_lines,
139
+ max_bytes=max_bytes,
140
+ )
141
+
142
+ output_lines: list[str] = []
143
+ output_bytes = 0
144
+ truncated_by: Literal["lines", "bytes"] = "lines"
145
+ for index, line in enumerate(lines):
146
+ if index >= max_lines:
147
+ break
148
+ line_bytes = _utf8_bytes(line) + (1 if index > 0 else 0)
149
+ if output_bytes + line_bytes > max_bytes:
150
+ truncated_by = "bytes"
151
+ break
152
+ output_lines.append(line)
153
+ output_bytes += line_bytes
154
+
155
+ if len(output_lines) >= max_lines and output_bytes <= max_bytes:
156
+ truncated_by = "lines"
157
+
158
+ output = "\n".join(output_lines)
159
+ return TruncationResult(
160
+ content=output,
161
+ truncated=True,
162
+ truncated_by=truncated_by,
163
+ total_lines=total_lines,
164
+ total_bytes=total_bytes,
165
+ output_lines=len(output_lines),
166
+ output_bytes=_utf8_bytes(output),
167
+ last_line_partial=False,
168
+ first_line_exceeds_limit=False,
169
+ max_lines=max_lines,
170
+ max_bytes=max_bytes,
171
+ )
172
+
173
+
174
+ def _truncate_from_end(text: str, max_bytes: int) -> str:
175
+ data = text.encode("utf-8")
176
+ if len(data) <= max_bytes:
177
+ return text
178
+ start = len(data) - max_bytes
179
+ while start < len(data) and data[start] & 0xC0 == 0x80:
180
+ start += 1
181
+ return data[start:].decode("utf-8")
182
+
183
+
184
+ def truncate_tail(
185
+ content: str,
186
+ *,
187
+ max_lines: int = DEFAULT_MAX_LINES,
188
+ max_bytes: int = DEFAULT_MAX_BYTES,
189
+ ) -> TruncationResult:
190
+ """Keep Pi's trailing lines, including its single-overlong-line edge case."""
191
+
192
+ content = sanitize_text(content)
193
+ total_bytes = _utf8_bytes(content)
194
+ lines = _split_lines_for_counting(content)
195
+ total_lines = len(lines)
196
+ if total_lines <= max_lines and total_bytes <= max_bytes:
197
+ return _unchanged_result(
198
+ content,
199
+ total_lines=total_lines,
200
+ total_bytes=total_bytes,
201
+ max_lines=max_lines,
202
+ max_bytes=max_bytes,
203
+ )
204
+
205
+ output_lines: list[str] = []
206
+ output_bytes = 0
207
+ truncated_by: Literal["lines", "bytes"] = "lines"
208
+ last_line_partial = False
209
+ for line in reversed(lines):
210
+ if len(output_lines) >= max_lines:
211
+ break
212
+ line_bytes = _utf8_bytes(line) + (1 if output_lines else 0)
213
+ if output_bytes + line_bytes > max_bytes:
214
+ truncated_by = "bytes"
215
+ if not output_lines:
216
+ output_lines.insert(0, _truncate_from_end(line, max_bytes))
217
+ last_line_partial = True
218
+ break
219
+ output_lines.insert(0, line)
220
+ output_bytes += line_bytes
221
+
222
+ if len(output_lines) >= max_lines and output_bytes <= max_bytes:
223
+ truncated_by = "lines"
224
+
225
+ output = "\n".join(output_lines)
226
+ return TruncationResult(
227
+ content=output,
228
+ truncated=True,
229
+ truncated_by=truncated_by,
230
+ total_lines=total_lines,
231
+ total_bytes=total_bytes,
232
+ output_lines=len(output_lines),
233
+ output_bytes=_utf8_bytes(output),
234
+ last_line_partial=last_line_partial,
235
+ first_line_exceeds_limit=False,
236
+ max_lines=max_lines,
237
+ max_bytes=max_bytes,
238
+ )
239
+
240
+
241
+ def truncate_line(
242
+ line: str, max_characters: int = GREP_MAX_LINE_LENGTH
243
+ ) -> tuple[str, bool]:
244
+ """Limit one grep line with Pi's model-visible suffix."""
245
+
246
+ line = sanitize_text(line)
247
+ if _utf16_code_unit_length(line) <= max_characters:
248
+ return line, False
249
+ return f"{_take_utf16_code_units(line, max_characters)}... [truncated]", True
250
+
251
+
252
+ def _utf16_code_unit_length(text: str) -> int:
253
+ return sum(2 if ord(character) > 0xFFFF else 1 for character in text)
254
+
255
+
256
+ def _take_utf16_code_units(text: str, maximum: int) -> str:
257
+ """Take JS string units without manufacturing a lone Python surrogate."""
258
+
259
+ units = 0
260
+ result: list[str] = []
261
+ for character in text:
262
+ character_units = 2 if ord(character) > 0xFFFF else 1
263
+ if units + character_units > maximum:
264
+ break
265
+ result.append(character)
266
+ units += character_units
267
+ return "".join(result)
@@ -0,0 +1,396 @@
1
+ """Descriptor-only Pi-compatible unrestricted tool catalog data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from millforge import IdempotencyClass, SideEffectClass
10
+ from millforge.compiler.catalogs import ToolCatalogSnapshot
11
+ from millforge.tools.registry import (
12
+ ToolDescriptor,
13
+ ToolOutputPolicy,
14
+ ToolRegistry,
15
+ ToolTimeoutPolicy,
16
+ )
17
+
18
+ JsonObject = dict[str, Any]
19
+
20
+ PI_COMPAT_TOOL_VERSION = 1
21
+ DEFAULT_BASE_TERMINAL_RESULTS: tuple[str, ...] = ("BLOCKED", "COMPLETE", "REJECTED")
22
+
23
+ PI_COMPAT_CAP_FILESYSTEM_READ = "unrestricted.filesystem.read"
24
+ PI_COMPAT_CAP_FILESYSTEM_WRITE = "unrestricted.filesystem.write"
25
+ PI_COMPAT_CAP_PROCESS_EXECUTE = "unrestricted.process.execute"
26
+ PI_COMPAT_CAP_TERMINAL_INTENT = "terminal.intent"
27
+
28
+ PI_COMPAT_CAPABILITY_IDS = (
29
+ PI_COMPAT_CAP_FILESYSTEM_READ,
30
+ PI_COMPAT_CAP_FILESYSTEM_WRITE,
31
+ PI_COMPAT_CAP_PROCESS_EXECUTE,
32
+ PI_COMPAT_CAP_TERMINAL_INTENT,
33
+ )
34
+
35
+ _PI_TIMEOUT_POLICY = ToolTimeoutPolicy(
36
+ timeout_seconds=1800,
37
+ cancellation_grace_seconds=30,
38
+ )
39
+ _TERMINAL_TIMEOUT_POLICY = ToolTimeoutPolicy(
40
+ timeout_seconds=30,
41
+ cancellation_grace_seconds=5,
42
+ )
43
+ _OUTPUT_POLICY = ToolOutputPolicy(
44
+ max_output_bytes=131_072,
45
+ max_summary_utf8=65_536,
46
+ redact_secrets=False,
47
+ )
48
+
49
+ _STRING_SCHEMA: JsonObject = {"type": "string"}
50
+ _NUMBER_SCHEMA: JsonObject = {"type": "number"}
51
+ _BOOLEAN_SCHEMA: JsonObject = {"type": "boolean"}
52
+
53
+
54
+ def _object_schema(properties: JsonObject, required: list[str]) -> JsonObject:
55
+ return {
56
+ "type": "object",
57
+ "properties": properties,
58
+ "required": required,
59
+ "additionalProperties": False,
60
+ }
61
+
62
+
63
+ _READ_INPUT_SCHEMA = _object_schema(
64
+ {
65
+ "path": _STRING_SCHEMA,
66
+ "offset": _NUMBER_SCHEMA,
67
+ "limit": _NUMBER_SCHEMA,
68
+ },
69
+ ["path"],
70
+ )
71
+ _BASH_INPUT_SCHEMA = _object_schema(
72
+ {
73
+ "command": _STRING_SCHEMA,
74
+ "timeout": _NUMBER_SCHEMA,
75
+ },
76
+ ["command"],
77
+ )
78
+ _EDIT_INPUT_SCHEMA = _object_schema(
79
+ {
80
+ "path": _STRING_SCHEMA,
81
+ "edits": {
82
+ "type": "array",
83
+ "items": _object_schema(
84
+ {
85
+ "oldText": _STRING_SCHEMA,
86
+ "newText": _STRING_SCHEMA,
87
+ },
88
+ ["oldText", "newText"],
89
+ ),
90
+ },
91
+ },
92
+ ["path", "edits"],
93
+ )
94
+ _WRITE_INPUT_SCHEMA = _object_schema(
95
+ {
96
+ "path": _STRING_SCHEMA,
97
+ "content": _STRING_SCHEMA,
98
+ },
99
+ ["path", "content"],
100
+ )
101
+ _GREP_INPUT_SCHEMA = _object_schema(
102
+ {
103
+ "pattern": _STRING_SCHEMA,
104
+ "path": _STRING_SCHEMA,
105
+ "glob": _STRING_SCHEMA,
106
+ "ignoreCase": _BOOLEAN_SCHEMA,
107
+ "literal": _BOOLEAN_SCHEMA,
108
+ "context": _NUMBER_SCHEMA,
109
+ "limit": _NUMBER_SCHEMA,
110
+ },
111
+ ["pattern"],
112
+ )
113
+ _FIND_INPUT_SCHEMA = _object_schema(
114
+ {
115
+ "pattern": _STRING_SCHEMA,
116
+ "path": _STRING_SCHEMA,
117
+ "limit": _NUMBER_SCHEMA,
118
+ },
119
+ ["pattern"],
120
+ )
121
+ _LS_INPUT_SCHEMA = _object_schema(
122
+ {
123
+ "path": _STRING_SCHEMA,
124
+ "limit": _NUMBER_SCHEMA,
125
+ },
126
+ [],
127
+ )
128
+ _OUTPUT_SCHEMA = _object_schema(
129
+ {
130
+ "model_text": _STRING_SCHEMA,
131
+ "truncated": _BOOLEAN_SCHEMA,
132
+ "exit_code": {"type": "integer"},
133
+ "changed_path": _STRING_SCHEMA,
134
+ },
135
+ ["model_text", "truncated"],
136
+ )
137
+
138
+
139
+ def _terminal_input_schema(terminal_result: str) -> JsonObject:
140
+ return _object_schema(
141
+ {
142
+ "terminal_result": {"type": "string", "enum": [terminal_result]},
143
+ "summary": _STRING_SCHEMA,
144
+ },
145
+ ["terminal_result", "summary"],
146
+ )
147
+
148
+
149
+ def _descriptor(
150
+ *,
151
+ tool_id: str,
152
+ model_tool_name: str,
153
+ description: str,
154
+ input_schema: Mapping[str, Any],
155
+ required_capabilities: tuple[str, ...],
156
+ side_effect_class: SideEffectClass,
157
+ idempotency: IdempotencyClass,
158
+ timeout_policy: ToolTimeoutPolicy = _PI_TIMEOUT_POLICY,
159
+ ) -> ToolDescriptor:
160
+ return ToolDescriptor.model_validate(
161
+ {
162
+ "tool_id": tool_id,
163
+ "tool_version": PI_COMPAT_TOOL_VERSION,
164
+ "implementation_id": f"impl.{tool_id}.v1",
165
+ "model_tool_name": model_tool_name,
166
+ "description": description,
167
+ "input_schema": input_schema,
168
+ "output_schema": _OUTPUT_SCHEMA,
169
+ "required_capabilities": required_capabilities,
170
+ "produced_artifact_ids": (),
171
+ "side_effect_class": side_effect_class,
172
+ "idempotency": idempotency,
173
+ "timeout_policy": timeout_policy,
174
+ "output_policy": _OUTPUT_POLICY,
175
+ }
176
+ )
177
+
178
+
179
+ PI_COMPAT_TOOL_DESCRIPTORS: tuple[ToolDescriptor, ...] = (
180
+ _descriptor(
181
+ tool_id="builtin.pi_compat.read",
182
+ model_tool_name="read",
183
+ description=(
184
+ "Read the contents of a file. Supports text files. Supported image files "
185
+ "return bounded text-only metadata. Text output is truncated to 2000 "
186
+ "lines or 50KB, whichever is reached first. Use offset and limit to "
187
+ "continue."
188
+ ),
189
+ input_schema=_READ_INPUT_SCHEMA,
190
+ required_capabilities=(PI_COMPAT_CAP_FILESYSTEM_READ,),
191
+ side_effect_class=SideEffectClass.READ_ONLY,
192
+ idempotency=IdempotencyClass.IDEMPOTENT,
193
+ ),
194
+ _descriptor(
195
+ tool_id="builtin.pi_compat.bash",
196
+ model_tool_name="bash",
197
+ description=(
198
+ "Execute a command in the current working directory using Millforge's "
199
+ "resolved shell. Returns combined stdout and stderr. Output is truncated "
200
+ "to the last 2000 lines or 50KB, whichever is reached first. If "
201
+ "truncated, full output is saved to a temporary file. Timeout may only "
202
+ "lower the harness maximum."
203
+ ),
204
+ input_schema=_BASH_INPUT_SCHEMA,
205
+ required_capabilities=(PI_COMPAT_CAP_PROCESS_EXECUTE,),
206
+ side_effect_class=SideEffectClass.PROCESS_EXECUTION,
207
+ idempotency=IdempotencyClass.NON_IDEMPOTENT,
208
+ ),
209
+ _descriptor(
210
+ tool_id="builtin.pi_compat.edit",
211
+ model_tool_name="edit",
212
+ description=(
213
+ "Edit a single file using exact text replacement. Every edits[].oldText "
214
+ "must match a unique, non-overlapping region of the original file. If two "
215
+ "changes affect the same block or nearby lines, merge them into one edit "
216
+ "instead of emitting overlapping edits. Do not include large unchanged "
217
+ "regions just to connect distant changes."
218
+ ),
219
+ input_schema=_EDIT_INPUT_SCHEMA,
220
+ required_capabilities=(
221
+ PI_COMPAT_CAP_FILESYSTEM_READ,
222
+ PI_COMPAT_CAP_FILESYSTEM_WRITE,
223
+ ),
224
+ side_effect_class=SideEffectClass.WORKSPACE_WRITE,
225
+ idempotency=IdempotencyClass.IDEMPOTENT_WITH_KEY,
226
+ ),
227
+ _descriptor(
228
+ tool_id="builtin.pi_compat.write",
229
+ model_tool_name="write",
230
+ description=(
231
+ "Write content to a file. Creates the file if it doesn't exist, overwrites "
232
+ "if it does. Automatically creates parent directories."
233
+ ),
234
+ input_schema=_WRITE_INPUT_SCHEMA,
235
+ required_capabilities=(PI_COMPAT_CAP_FILESYSTEM_WRITE,),
236
+ side_effect_class=SideEffectClass.WORKSPACE_WRITE,
237
+ idempotency=IdempotencyClass.IDEMPOTENT_WITH_KEY,
238
+ ),
239
+ _descriptor(
240
+ tool_id="builtin.pi_compat.grep",
241
+ model_tool_name="grep",
242
+ description=(
243
+ "Search file contents for a pattern. Returns matching lines with file "
244
+ "paths and line numbers. Respects .gitignore. Output is truncated to 100 "
245
+ "matches or 50KB, whichever is reached first. Long lines are truncated to "
246
+ "500 characters."
247
+ ),
248
+ input_schema=_GREP_INPUT_SCHEMA,
249
+ required_capabilities=(PI_COMPAT_CAP_FILESYSTEM_READ,),
250
+ side_effect_class=SideEffectClass.READ_ONLY,
251
+ idempotency=IdempotencyClass.IDEMPOTENT,
252
+ ),
253
+ _descriptor(
254
+ tool_id="builtin.pi_compat.find",
255
+ model_tool_name="find",
256
+ description=(
257
+ "Search for files by glob pattern. Returns matching paths relative to the "
258
+ "search directory. Respects .gitignore. Output is truncated to 1000 "
259
+ "results or 50KB, whichever is reached first."
260
+ ),
261
+ input_schema=_FIND_INPUT_SCHEMA,
262
+ required_capabilities=(PI_COMPAT_CAP_FILESYSTEM_READ,),
263
+ side_effect_class=SideEffectClass.READ_ONLY,
264
+ idempotency=IdempotencyClass.IDEMPOTENT,
265
+ ),
266
+ _descriptor(
267
+ tool_id="builtin.pi_compat.ls",
268
+ model_tool_name="ls",
269
+ description=(
270
+ "List directory contents. Returns entries sorted alphabetically, with '/' "
271
+ "suffix for directories. Includes dotfiles. Output is truncated to 500 "
272
+ "entries or 50KB, whichever is reached first."
273
+ ),
274
+ input_schema=_LS_INPUT_SCHEMA,
275
+ required_capabilities=(PI_COMPAT_CAP_FILESYSTEM_READ,),
276
+ side_effect_class=SideEffectClass.READ_ONLY,
277
+ idempotency=IdempotencyClass.IDEMPOTENT,
278
+ ),
279
+ _descriptor(
280
+ tool_id="builtin.pi_compat.submit",
281
+ model_tool_name="submit",
282
+ description="Complete the current Millforge run.",
283
+ input_schema=_terminal_input_schema("COMPLETE"),
284
+ required_capabilities=(PI_COMPAT_CAP_TERMINAL_INTENT,),
285
+ side_effect_class=SideEffectClass.TERMINAL,
286
+ idempotency=IdempotencyClass.NON_IDEMPOTENT,
287
+ timeout_policy=_TERMINAL_TIMEOUT_POLICY,
288
+ ),
289
+ _descriptor(
290
+ tool_id="builtin.pi_compat.block",
291
+ model_tool_name="block",
292
+ description="Report that the current Millforge run is blocked.",
293
+ input_schema=_terminal_input_schema("BLOCKED"),
294
+ required_capabilities=(PI_COMPAT_CAP_TERMINAL_INTENT,),
295
+ side_effect_class=SideEffectClass.TERMINAL,
296
+ idempotency=IdempotencyClass.NON_IDEMPOTENT,
297
+ timeout_policy=_TERMINAL_TIMEOUT_POLICY,
298
+ ),
299
+ _descriptor(
300
+ tool_id="builtin.pi_compat.reject",
301
+ model_tool_name="reject",
302
+ description="Reject the current Millforge run.",
303
+ input_schema=_terminal_input_schema("REJECTED"),
304
+ required_capabilities=(PI_COMPAT_CAP_TERMINAL_INTENT,),
305
+ side_effect_class=SideEffectClass.TERMINAL,
306
+ idempotency=IdempotencyClass.NON_IDEMPOTENT,
307
+ timeout_policy=_TERMINAL_TIMEOUT_POLICY,
308
+ ),
309
+ )
310
+
311
+
312
+ def _configured_terminal_descriptors(
313
+ legal_terminal_results: tuple[str, ...],
314
+ ) -> tuple[ToolDescriptor, ...]:
315
+ tokens = tuple(_terminal_token(result) for result in legal_terminal_results)
316
+ if len(set(tokens)) != len(tokens):
317
+ raise ValueError("configured terminal tool identities collide")
318
+ return tuple(
319
+ _descriptor(
320
+ tool_id=f"builtin.pi_compat.terminal.{token}",
321
+ model_tool_name=f"terminal_{token}",
322
+ description=(
323
+ f"Return terminal result {result_id} with a nonblank summary."
324
+ ),
325
+ input_schema=_terminal_input_schema(result_id),
326
+ required_capabilities=(PI_COMPAT_CAP_TERMINAL_INTENT,),
327
+ side_effect_class=SideEffectClass.TERMINAL,
328
+ idempotency=IdempotencyClass.NON_IDEMPOTENT,
329
+ timeout_policy=_TERMINAL_TIMEOUT_POLICY,
330
+ )
331
+ for result_id, token in zip(legal_terminal_results, tokens, strict=True)
332
+ )
333
+
334
+
335
+ def _terminal_token(result_id: str) -> str:
336
+ slug = result_id.lower()
337
+ if len(slug) <= 48:
338
+ return slug
339
+ return f"{slug[:39]}_{hashlib.sha256(result_id.encode()).hexdigest()[:8]}"
340
+
341
+
342
+ def _descriptors_for_terminal_results(
343
+ legal_terminal_results: tuple[str, ...],
344
+ ) -> tuple[ToolDescriptor, ...]:
345
+ if legal_terminal_results == DEFAULT_BASE_TERMINAL_RESULTS:
346
+ return PI_COMPAT_TOOL_DESCRIPTORS
347
+ return (
348
+ *PI_COMPAT_TOOL_DESCRIPTORS[:-3],
349
+ *_configured_terminal_descriptors(legal_terminal_results),
350
+ )
351
+
352
+
353
+ def create_pi_compat_tool_registry() -> ToolRegistry:
354
+ """Create a registry populated only with Pi-compatible descriptors."""
355
+
356
+ return _create_pi_compat_tool_registry_for_terminal_results(
357
+ DEFAULT_BASE_TERMINAL_RESULTS
358
+ )
359
+
360
+
361
+ def _create_pi_compat_tool_registry_for_terminal_results(
362
+ legal_terminal_results: tuple[str, ...],
363
+ ) -> ToolRegistry:
364
+ """Create a registry from already-canonical terminal configuration."""
365
+
366
+ registry = ToolRegistry()
367
+ for descriptor in _descriptors_for_terminal_results(legal_terminal_results):
368
+ registry.register(descriptor)
369
+ return registry
370
+
371
+
372
+ def create_pi_compat_tool_snapshot() -> ToolCatalogSnapshot:
373
+ """Create a frozen exact-version snapshot for the Pi-compatible catalog."""
374
+
375
+ return _create_pi_compat_tool_snapshot_for_terminal_results(
376
+ DEFAULT_BASE_TERMINAL_RESULTS
377
+ )
378
+
379
+
380
+ def _create_pi_compat_tool_snapshot_for_terminal_results(
381
+ legal_terminal_results: tuple[str, ...],
382
+ ) -> ToolCatalogSnapshot:
383
+ """Create a frozen snapshot from already-canonical terminal configuration."""
384
+
385
+ return _create_pi_compat_tool_registry_for_terminal_results(
386
+ legal_terminal_results
387
+ ).freeze()
388
+
389
+
390
+ __all__ = [
391
+ "PI_COMPAT_CAPABILITY_IDS",
392
+ "PI_COMPAT_TOOL_DESCRIPTORS",
393
+ "PI_COMPAT_TOOL_VERSION",
394
+ "create_pi_compat_tool_registry",
395
+ "create_pi_compat_tool_snapshot",
396
+ ]