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 +232 -0
- client.py +160 -0
- contract.py +293 -0
- crypto.py +64 -0
- fix.py +2596 -0
- fix_cli-0.4.0.dist-info/METADATA +108 -0
- fix_cli-0.4.0.dist-info/RECORD +20 -0
- fix_cli-0.4.0.dist-info/WHEEL +5 -0
- fix_cli-0.4.0.dist-info/entry_points.txt +2 -0
- fix_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
- fix_cli-0.4.0.dist-info/top_level.txt +8 -0
- protocol.py +142 -0
- scrubber.py +355 -0
- server/__init__.py +8 -0
- server/app.py +1142 -0
- server/escrow.py +391 -0
- server/judge.py +269 -0
- server/nano.py +164 -0
- server/reputation.py +142 -0
- server/store.py +154 -0
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
|