fix-cli 0.4.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.
agent.py ADDED
@@ -0,0 +1,232 @@
1
+ """Agent mode for fix v3 — polls platform for contracts, investigates, proposes fixes.
2
+
3
+ Runs via `fix serve`. Connects to the platform via FixClient,
4
+ polls for open contracts, accepts compatible ones, runs LLM-driven
5
+ investigation loop, and proposes fixes.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import re
11
+ import sys
12
+ import os
13
+ from decimal import Decimal
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
16
+
17
+ from protocol import MAX_INVESTIGATION_ROUNDS
18
+ from client import FixClient
19
+ from contract import validate_contract, contract_for_prompt
20
+
21
+
22
+ # --- Helper functions ---
23
+
24
+ def parse_llm_investigation(response: str) -> list[str]:
25
+ """Extract INVESTIGATE: commands from LLM response."""
26
+ commands = []
27
+ for line in response.splitlines():
28
+ stripped = line.strip()
29
+ m = re.match(r'^INVESTIGATE:\s*(.+)$', stripped, re.IGNORECASE)
30
+ if m:
31
+ cmd = m.group(1).strip()
32
+ if cmd:
33
+ commands.append(cmd)
34
+ return commands
35
+
36
+
37
+ def build_agent_prompt(contract: dict, investigation_results: list[dict]) -> str:
38
+ """Build the prompt for the LLM with contract and investigation context."""
39
+ parts = []
40
+ parts.append("You are a fix agent. Your job is to diagnose and fix the problem described in this contract.\n")
41
+ parts.append("## Contract\n")
42
+ parts.append(json.dumps(contract, indent=2))
43
+ parts.append("")
44
+
45
+ if investigation_results:
46
+ parts.append("## Investigation Results So Far\n")
47
+ for i, r in enumerate(investigation_results, 1):
48
+ parts.append(f"### Round {i}: `{r['command']}`")
49
+ parts.append(f"```\n{r['output']}\n```")
50
+ parts.append("")
51
+
52
+ parts.append("## Instructions\n")
53
+ parts.append(
54
+ "If you need more information, output one or more lines starting with "
55
+ "'INVESTIGATE: <command>' where <command> is a shell command to run on "
56
+ "the principal's machine.\n"
57
+ )
58
+ parts.append(
59
+ "When you have enough information to propose a fix, output a JSON block:\n"
60
+ "```json\n"
61
+ '{"fix": "<shell command or script>", "explanation": "<why this fixes it>"}\n'
62
+ "```\n"
63
+ )
64
+ return "\n".join(parts)
65
+
66
+
67
+ def capabilities_match(agent_caps: dict, contract: dict) -> tuple[bool, str]:
68
+ """Check if agent capabilities satisfy contract requirements."""
69
+ contract_caps = contract.get("capabilities", {})
70
+ if not contract_caps:
71
+ return True, ""
72
+
73
+ for cap_name, cap_spec in contract_caps.items():
74
+ if not isinstance(cap_spec, dict):
75
+ continue
76
+ required = cap_spec.get("available", False)
77
+ if not required:
78
+ continue
79
+ agent_cap = agent_caps.get(cap_name, {})
80
+ if not isinstance(agent_cap, dict):
81
+ return False, f"missing capability: {cap_name}"
82
+ if not agent_cap.get("available", False):
83
+ return False, f"capability not available: {cap_name}"
84
+
85
+ return True, ""
86
+
87
+
88
+ class FixAgent:
89
+ """Agent that polls the fix platform for contracts and processes them."""
90
+
91
+ def __init__(self, config: dict):
92
+ """
93
+ config keys:
94
+ - platform_url: str (URL of the fix platform, default http://localhost:8000)
95
+ - api_key: str (platform API key)
96
+ - pubkey: str (agent's public key identifier)
97
+ - capabilities: dict
98
+ - min_bounty: str (minimum bounty to accept, default "0")
99
+ - llm_call: async callable(prompt) -> str
100
+ - poll_interval: float (seconds between polls, default 5.0)
101
+ """
102
+ self.platform_url = config.get("platform_url", "https://fix.notruefireman.org")
103
+ self.api_key = config.get("api_key", "")
104
+ self.pubkey = config.get("pubkey", "agent-default")
105
+ self.capabilities = config.get("capabilities", {})
106
+ self.min_bounty = Decimal(config.get("min_bounty", "0"))
107
+ self._llm_call = config.get("llm_call")
108
+ self.poll_interval = config.get("poll_interval", 5.0)
109
+ self._running = False
110
+ self.client = FixClient(base_url=self.platform_url)
111
+
112
+ async def serve(self):
113
+ """Main loop: poll platform for open contracts, process them."""
114
+ self._running = True
115
+ while self._running:
116
+ try:
117
+ contracts = await self.client.list_contracts(status="open")
118
+ for entry in contracts:
119
+ contract = entry.get("contract", {})
120
+ contract_id = entry.get("id", "")
121
+ valid, errors = validate_contract(contract)
122
+ if not valid:
123
+ continue
124
+ can, reason = self.can_handle(contract)
125
+ if not can:
126
+ continue
127
+ await self.handle_contract(contract_id, contract)
128
+ except Exception:
129
+ pass # Log and continue
130
+ await asyncio.sleep(self.poll_interval)
131
+
132
+ def stop(self):
133
+ self._running = False
134
+
135
+ async def handle_contract(self, contract_id: str, contract: dict):
136
+ """Process a single contract: accept, investigate, propose fix."""
137
+ # 1. Accept
138
+ await self.client.accept_contract(contract_id, self.pubkey)
139
+
140
+ # 2. Investigation loop
141
+ investigation_results = []
142
+ prompt_contract = contract_for_prompt(contract)
143
+ max_rounds = contract.get("execution", {}).get(
144
+ "investigation_rounds", MAX_INVESTIGATION_ROUNDS
145
+ )
146
+
147
+ fix_data = None
148
+ for _round in range(max_rounds):
149
+ prompt = build_agent_prompt(prompt_contract, investigation_results)
150
+ llm_response = await self._call_llm(prompt)
151
+
152
+ fix_data = self._extract_fix_proposal(llm_response)
153
+ if fix_data:
154
+ break
155
+
156
+ commands = parse_llm_investigation(llm_response)
157
+ if not commands:
158
+ break
159
+
160
+ for cmd in commands:
161
+ await self.client.request_investigation(contract_id, cmd, self.pubkey)
162
+ # Poll for result
163
+ result_output = await self._wait_for_result(contract_id, cmd)
164
+ if result_output is not None:
165
+ investigation_results.append({"command": cmd, "output": result_output})
166
+ else:
167
+ # Exhausted rounds — one final LLM call
168
+ prompt = build_agent_prompt(prompt_contract, investigation_results)
169
+ llm_response = await self._call_llm(prompt)
170
+ fix_data = self._extract_fix_proposal(llm_response)
171
+
172
+ # 3. Submit fix
173
+ if not fix_data:
174
+ fix_data = {"fix": "", "explanation": "Unable to determine a fix within investigation rounds."}
175
+
176
+ await self.client.submit_fix(
177
+ contract_id,
178
+ fix_data["fix"],
179
+ fix_data.get("explanation", ""),
180
+ self.pubkey,
181
+ )
182
+
183
+ def can_handle(self, contract: dict) -> tuple[bool, str]:
184
+ """Check if this agent can handle the contract."""
185
+ escrow = contract.get("escrow", {})
186
+ if escrow:
187
+ bounty = Decimal(escrow.get("bounty", "0"))
188
+ if bounty < self.min_bounty:
189
+ return False, f"bounty {bounty} below minimum {self.min_bounty}"
190
+ match, reason = capabilities_match(self.capabilities, contract)
191
+ if not match:
192
+ return False, reason
193
+ return True, ""
194
+
195
+ async def _call_llm(self, prompt: str) -> str:
196
+ if self._llm_call:
197
+ return await self._llm_call(prompt)
198
+ raise NotImplementedError("LLM backend not configured. Inject llm_call in config.")
199
+
200
+ @staticmethod
201
+ def _extract_fix_proposal(llm_response: str) -> dict | None:
202
+ """Try to extract a JSON fix proposal from LLM response."""
203
+ m = re.search(r'```(?:json)?\s*\n?({.*?})\s*\n?```', llm_response, re.DOTALL)
204
+ if m:
205
+ try:
206
+ data = json.loads(m.group(1))
207
+ if "fix" in data and "explanation" in data:
208
+ return data
209
+ except json.JSONDecodeError:
210
+ pass
211
+ m = re.search(r'(\{[^{}]*"fix"[^{}]*"explanation"[^{}]*\})', llm_response, re.DOTALL)
212
+ if m:
213
+ try:
214
+ data = json.loads(m.group(1))
215
+ if "fix" in data and "explanation" in data:
216
+ return data
217
+ except json.JSONDecodeError:
218
+ pass
219
+ return None
220
+
221
+ async def _wait_for_result(self, contract_id: str, command: str, timeout: float = 60.0, poll: float = 1.0) -> str | None:
222
+ """Poll for investigation result from principal."""
223
+ import time
224
+ deadline = time.time() + timeout
225
+ while time.time() < deadline:
226
+ data = await self.client.get_contract(contract_id)
227
+ if data:
228
+ for msg in reversed(data.get("transcript", [])):
229
+ if msg.get("type") == "result" and msg.get("command") == command:
230
+ return msg.get("output", "")
231
+ await asyncio.sleep(poll)
232
+ return None
client.py ADDED
@@ -0,0 +1,160 @@
1
+ """Platform API client for fix protocol.
2
+
3
+ Thin HTTP client with a pluggable transport interface.
4
+ Default transport: HTTP to our centralized platform.
5
+ """
6
+
7
+ import json
8
+ from abc import ABC, abstractmethod
9
+
10
+ import httpx
11
+
12
+
13
+ class Transport(ABC):
14
+ """Override this to use Nostr, gRPC, pigeons, whatever."""
15
+
16
+ @abstractmethod
17
+ async def post(self, path: str, data: dict) -> dict:
18
+ ...
19
+
20
+ @abstractmethod
21
+ async def get(self, path: str, params: dict | None = None) -> dict:
22
+ ...
23
+
24
+
25
+ class HTTPTransport(Transport):
26
+ """Default. Talks to our centralized platform over HTTP."""
27
+
28
+ def __init__(self, base_url: str = "http://localhost:8000", api_key: str = ""):
29
+ self.base_url = base_url.rstrip("/")
30
+ self.api_key = api_key
31
+
32
+ def _headers(self) -> dict:
33
+ h = {"Content-Type": "application/json"}
34
+ if self.api_key:
35
+ h["Authorization"] = f"Bearer {self.api_key}"
36
+ return h
37
+
38
+ async def post(self, path: str, data: dict) -> dict:
39
+ async with httpx.AsyncClient() as client:
40
+ resp = await client.post(
41
+ f"{self.base_url}{path}",
42
+ json=data,
43
+ headers=self._headers(),
44
+ timeout=30.0,
45
+ )
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ async def get(self, path: str, params: dict | None = None) -> dict:
50
+ async with httpx.AsyncClient() as client:
51
+ resp = await client.get(
52
+ f"{self.base_url}{path}",
53
+ params=params,
54
+ headers=self._headers(),
55
+ timeout=30.0,
56
+ )
57
+ resp.raise_for_status()
58
+ return resp.json()
59
+
60
+
61
+ class FixClient:
62
+ """High-level client for the fix platform."""
63
+
64
+ def __init__(self, transport: Transport | None = None, base_url: str = "http://localhost:8000"):
65
+ self.transport = transport or HTTPTransport(base_url)
66
+
67
+ async def post_contract(self, contract: dict, principal_pubkey: str = "") -> str:
68
+ """Post a contract. Returns contract_id."""
69
+ resp = await self.transport.post("/contracts", {
70
+ "contract": contract,
71
+ "principal_pubkey": principal_pubkey,
72
+ })
73
+ return resp["contract_id"]
74
+
75
+ async def list_contracts(self, status: str = "open", limit: int = 50) -> list[dict]:
76
+ """List contracts by status."""
77
+ resp = await self.transport.get("/contracts", {"status": status, "limit": limit})
78
+ return resp["contracts"]
79
+
80
+ async def get_contract(self, contract_id: str) -> dict:
81
+ """Get contract details."""
82
+ return await self.transport.get(f"/contracts/{contract_id}")
83
+
84
+ async def accept_contract(self, contract_id: str, agent_pubkey: str) -> dict:
85
+ """Agent accepts a contract."""
86
+ return await self.transport.post(f"/contracts/{contract_id}/accept", {
87
+ "agent_pubkey": agent_pubkey,
88
+ })
89
+
90
+ async def request_investigation(self, contract_id: str, command: str, agent_pubkey: str = "") -> dict:
91
+ """Agent requests investigation."""
92
+ return await self.transport.post(f"/contracts/{contract_id}/investigate", {
93
+ "command": command,
94
+ "agent_pubkey": agent_pubkey,
95
+ })
96
+
97
+ async def submit_investigation_result(self, contract_id: str, command: str, output: str) -> dict:
98
+ """Principal submits investigation result."""
99
+ return await self.transport.post(f"/contracts/{contract_id}/result", {
100
+ "command": command,
101
+ "output": output,
102
+ })
103
+
104
+ async def submit_fix(self, contract_id: str, fix: str, explanation: str = "", agent_pubkey: str = "") -> dict:
105
+ """Agent submits a fix."""
106
+ return await self.transport.post(f"/contracts/{contract_id}/fix", {
107
+ "fix": fix,
108
+ "explanation": explanation,
109
+ "agent_pubkey": agent_pubkey,
110
+ })
111
+
112
+ async def verify(self, contract_id: str, success: bool, explanation: str = "") -> dict:
113
+ """Principal reports verification result."""
114
+ return await self.transport.post(f"/contracts/{contract_id}/verify", {
115
+ "success": success,
116
+ "explanation": explanation,
117
+ })
118
+
119
+ async def dispute(self, contract_id: str, argument: str, side: str = "principal") -> dict:
120
+ """File a dispute. Returns awaiting_response status with deadline.
121
+ Other side has 30s to respond before judge rules in absentia."""
122
+ return await self.transport.post(f"/contracts/{contract_id}/dispute", {
123
+ "argument": argument,
124
+ "side": side,
125
+ })
126
+
127
+ async def respond(self, contract_id: str, argument: str, side: str) -> dict:
128
+ """Respond to a pending dispute. Triggers judge ruling with both arguments."""
129
+ return await self.transport.post(f"/contracts/{contract_id}/respond", {
130
+ "argument": argument,
131
+ "side": side,
132
+ })
133
+
134
+ async def dispute_status(self, contract_id: str) -> dict:
135
+ """Check status of a pending dispute."""
136
+ return await self.transport.get(f"/contracts/{contract_id}/dispute_status")
137
+
138
+ async def chat(self, contract_id: str, message: str, from_side: str = "principal",
139
+ msg_type: str = "message") -> dict:
140
+ """Send a chat message."""
141
+ return await self.transport.post(f"/contracts/{contract_id}/chat", {
142
+ "message": message,
143
+ "from_side": from_side,
144
+ "msg_type": msg_type,
145
+ })
146
+
147
+ async def halt(self, contract_id: str, reason: str, principal_pubkey: str = "") -> dict:
148
+ """Emergency halt -- freeze contract and escalate to judge."""
149
+ return await self.transport.post(f"/contracts/{contract_id}/halt", {
150
+ "reason": reason,
151
+ "principal_pubkey": principal_pubkey,
152
+ })
153
+
154
+ async def get_ruling(self, contract_id: str) -> dict | None:
155
+ """Get ruling for a contract."""
156
+ return await self.transport.get(f"/contracts/{contract_id}/ruling")
157
+
158
+ async def get_reputation(self, pubkey: str) -> dict:
159
+ """Get reputation stats."""
160
+ return await self.transport.get(f"/reputation/{pubkey}")
contract.py ADDED
@@ -0,0 +1,293 @@
1
+ """Contract builder and validator for fix v2.
2
+
3
+ Builds, validates, and transforms contract objects used by the fix protocol.
4
+ Contracts define what needs fixing, what the agent is allowed to do, and how
5
+ to verify the result.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+
13
+ from protocol import DEFAULT_MAX_ATTEMPTS
14
+
15
+ from scrubber import scrub
16
+
17
+
18
+ # --- Capability detection ---
19
+
20
+ def detect_capabilities():
21
+ """Auto-detect what tools/permissions are available on this system."""
22
+ caps = {}
23
+
24
+ # sudo
25
+ has_sudo = shutil.which("sudo") is not None
26
+ passwordless = False
27
+ if has_sudo:
28
+ try:
29
+ subprocess.run(
30
+ ["sudo", "-n", "true"],
31
+ capture_output=True, timeout=5
32
+ )
33
+ passwordless = True
34
+ except (subprocess.SubprocessError, OSError):
35
+ pass
36
+ caps["sudo"] = {
37
+ "available": False,
38
+ "scope": [],
39
+ }
40
+
41
+ # network — always available (used for package fetches)
42
+ caps["network"] = {"available": True, "scope": ["packages"]}
43
+
44
+ # docker
45
+ caps["docker"] = {"available": shutil.which("docker") is not None}
46
+
47
+ # make
48
+ caps["make"] = {"available": shutil.which("make") is not None}
49
+
50
+ return caps
51
+
52
+
53
+ # --- Verification spec parsing ---
54
+
55
+ def _parse_verify_spec(spec):
56
+ """Turn a verify_spec into a verification list.
57
+
58
+ None -> [{"method": "exit_code", "expected": 0}]
59
+ "human" -> [{"method": "human_judgment"}]
60
+ "contains 'X'" -> [{"method": "output_match", "pattern": "X"}]
61
+ list -> recurse each element
62
+ dict -> pass through as-is
63
+ """
64
+ if spec is None:
65
+ return [{"method": "exit_code", "expected": 0}]
66
+
67
+ if isinstance(spec, list):
68
+ out = []
69
+ for s in spec:
70
+ out.extend(_parse_verify_spec(s))
71
+ return out
72
+
73
+ if isinstance(spec, dict):
74
+ return [spec]
75
+
76
+ if isinstance(spec, str):
77
+ if spec == "human":
78
+ return [{"method": "human_judgment"}]
79
+ m = re.match(r"^contains\s+['\"](.+)['\"]$", spec)
80
+ if m:
81
+ return [{"method": "output_match", "pattern": m.group(1)}]
82
+ # Fallback: treat as custom method name
83
+ return [{"method": spec}]
84
+
85
+ return [{"method": "exit_code", "expected": 0}]
86
+
87
+
88
+ # --- Contract builder ---
89
+
90
+ def build_contract(command, stderr, env_info, **kwargs):
91
+ """Build a v2 fix contract.
92
+
93
+ Args:
94
+ command: The command that failed.
95
+ stderr: Raw stderr output from the failure.
96
+ env_info: Dict from get_env_fingerprint().
97
+ **kwargs: Optional overrides (see module docstring).
98
+
99
+ Returns:
100
+ Contract dict (v2 schema).
101
+ """
102
+ verify_spec = kwargs.get("verify_spec")
103
+ safe_mode = kwargs.get("safe_mode", False)
104
+ attempt = kwargs.get("attempt", 0)
105
+ prior_failures = kwargs.get("prior_failures")
106
+ root = kwargs.get("root")
107
+ bounty = kwargs.get("bounty")
108
+ judge = kwargs.get("judge")
109
+ remote = kwargs.get("remote", False)
110
+ redaction_config = kwargs.get("redaction_config")
111
+
112
+ # Scrub stderr — always, even local
113
+ scrubbed_stderr, matched_cats = scrub(stderr, redaction_config)
114
+ scrubbed_stderr = scrubbed_stderr[:1000]
115
+
116
+ # Scrub environment fields
117
+ os_str = env_info.get("os", "")
118
+ release = env_info.get("release", "")
119
+ distro = env_info.get("distro", "")
120
+ os_display = f"{distro} ({os_str})" if distro else os_str
121
+ os_display, _ = scrub(os_display, redaction_config)
122
+
123
+ arch = env_info.get("machine", "")
124
+
125
+ pkg_managers = env_info.get("package_managers", [])
126
+
127
+ contract = {
128
+ "version": 2,
129
+ "protocol": "fix",
130
+ "task": {
131
+ "type": "fix_command",
132
+ "command": command,
133
+ "error": scrubbed_stderr,
134
+ },
135
+ "environment": {
136
+ "os": os_display,
137
+ "arch": arch,
138
+ "package_managers": pkg_managers,
139
+ },
140
+ "capabilities": detect_capabilities(),
141
+ "verification": _parse_verify_spec(verify_spec),
142
+ "execution": {
143
+ "sandbox": safe_mode,
144
+ "root": root,
145
+ "max_attempts": DEFAULT_MAX_ATTEMPTS,
146
+ "investigation_rounds": 5,
147
+ "timeout": 300,
148
+ },
149
+ "redaction": {
150
+ "enabled": bool(matched_cats) or redaction_config is not None,
151
+ "categories": sorted(matched_cats) if matched_cats else [
152
+ "env_vars", "tokens", "paths", "ips", "emails"
153
+ ],
154
+ "custom_patterns": (
155
+ redaction_config.get("custom_patterns", [])
156
+ if redaction_config else []
157
+ ),
158
+ },
159
+ }
160
+
161
+ # Prior failures (scrubbed)
162
+ if prior_failures:
163
+ scrubbed_failures = []
164
+ for f in prior_failures:
165
+ sf, _ = scrub(str(f), redaction_config)
166
+ scrubbed_failures.append(sf[:500])
167
+ contract["prior_failures"] = scrubbed_failures
168
+
169
+ # Remote mode: add escrow + terms
170
+ if remote:
171
+ bounty_str = str(bounty) if bounty else "0.01"
172
+ contract["escrow"] = {
173
+ "bounty": bounty_str,
174
+ "currency": "XNO",
175
+ "chain": "nano",
176
+ "settle": "nano_direct",
177
+ }
178
+ contract["terms"] = {
179
+ "cancellation": {
180
+ "agent_fee": "0.002",
181
+ "principal_fee": "0.002",
182
+ "grace_period": 30,
183
+ },
184
+ "abandonment": {
185
+ "timeout": 120,
186
+ "ruling": "escalate",
187
+ },
188
+ "platform": {
189
+ "evidence_hash_algo": "sha256",
190
+ },
191
+ }
192
+ if judge:
193
+ contract["terms"]["judge"] = judge
194
+
195
+ return contract
196
+
197
+
198
+ # --- Validator ---
199
+
200
+ def validate_contract(contract):
201
+ """Validate a v2 contract dict.
202
+
203
+ Returns:
204
+ (True, []) if valid, (False, [errors]) otherwise.
205
+ """
206
+ errors = []
207
+
208
+ if not isinstance(contract, dict):
209
+ return False, ["contract is not a dict"]
210
+
211
+ # Version
212
+ if contract.get("version") != 2:
213
+ errors.append(f"version must be 2, got {contract.get('version')}")
214
+
215
+ # Task fields
216
+ task = contract.get("task", {})
217
+ if not isinstance(task, dict):
218
+ errors.append("task must be a dict")
219
+ task = {}
220
+ if not task.get("command"):
221
+ errors.append("task.command is required")
222
+ if "error" not in task:
223
+ errors.append("task.error is required")
224
+
225
+ # Environment
226
+ env = contract.get("environment", {})
227
+ if not isinstance(env, dict):
228
+ errors.append("environment must be a dict")
229
+ env = {}
230
+ if not env.get("os"):
231
+ errors.append("environment.os is required")
232
+ if not env.get("arch"):
233
+ errors.append("environment.arch is required")
234
+
235
+ # Verification
236
+ verification = contract.get("verification")
237
+ if not isinstance(verification, list) or len(verification) == 0:
238
+ errors.append("verification must be a non-empty list")
239
+
240
+ # Execution
241
+ execution = contract.get("execution", {})
242
+ if not isinstance(execution, dict):
243
+ errors.append("execution must be a dict")
244
+ execution = {}
245
+ if not isinstance(execution.get("sandbox"), bool):
246
+ errors.append("execution.sandbox must be a bool")
247
+ max_att = execution.get("max_attempts")
248
+ if not isinstance(max_att, int) or max_att <= 0:
249
+ errors.append("execution.max_attempts must be an int > 0")
250
+
251
+ # Escrow (if present)
252
+ escrow = contract.get("escrow")
253
+ if escrow is not None:
254
+ if not isinstance(escrow, dict):
255
+ errors.append("escrow must be a dict")
256
+ else:
257
+ b = escrow.get("bounty", "")
258
+ try:
259
+ float(b)
260
+ except (ValueError, TypeError):
261
+ errors.append(f"escrow.bounty must be a numeric string, got '{b}'")
262
+ if not escrow.get("currency"):
263
+ errors.append("escrow.currency is required when escrow is present")
264
+
265
+ return (len(errors) == 0, errors)
266
+
267
+
268
+ # --- Prompt projection ---
269
+
270
+ def contract_for_prompt(contract):
271
+ """Strip internal fields, return a copy suitable for LLM consumption.
272
+
273
+ Removes: redaction config internals, agent metadata.
274
+ Keeps: task, environment, capabilities, verification, execution basics, escrow/terms.
275
+ """
276
+ keep_keys = {
277
+ "version", "protocol", "task", "environment", "capabilities",
278
+ "verification", "execution", "prior_failures", "escrow", "terms",
279
+ }
280
+ out = {k: v for k, v in contract.items() if k in keep_keys}
281
+
282
+ # Strip redaction internals but note if redaction is active
283
+ if contract.get("redaction", {}).get("enabled"):
284
+ out["redaction"] = {"enabled": True}
285
+
286
+ # Strip execution internals that the LLM doesn't need
287
+ if "execution" in out:
288
+ out["execution"] = {
289
+ k: v for k, v in out["execution"].items()
290
+ if k in ("sandbox", "root", "max_attempts", "investigation_rounds", "timeout")
291
+ }
292
+
293
+ return out