fix-cli 0.4.0__tar.gz
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.
- fix_cli-0.4.0/LICENSE +21 -0
- fix_cli-0.4.0/PKG-INFO +108 -0
- fix_cli-0.4.0/README.md +92 -0
- fix_cli-0.4.0/agent.py +232 -0
- fix_cli-0.4.0/client.py +160 -0
- fix_cli-0.4.0/contract.py +293 -0
- fix_cli-0.4.0/crypto.py +64 -0
- fix_cli-0.4.0/fix.py +2596 -0
- fix_cli-0.4.0/fix_cli.egg-info/PKG-INFO +108 -0
- fix_cli-0.4.0/fix_cli.egg-info/SOURCES.txt +35 -0
- fix_cli-0.4.0/fix_cli.egg-info/dependency_links.txt +1 -0
- fix_cli-0.4.0/fix_cli.egg-info/entry_points.txt +2 -0
- fix_cli-0.4.0/fix_cli.egg-info/requires.txt +6 -0
- fix_cli-0.4.0/fix_cli.egg-info/top_level.txt +8 -0
- fix_cli-0.4.0/protocol.py +142 -0
- fix_cli-0.4.0/pyproject.toml +35 -0
- fix_cli-0.4.0/scrubber.py +355 -0
- fix_cli-0.4.0/server/__init__.py +8 -0
- fix_cli-0.4.0/server/app.py +1142 -0
- fix_cli-0.4.0/server/escrow.py +391 -0
- fix_cli-0.4.0/server/judge.py +269 -0
- fix_cli-0.4.0/server/nano.py +164 -0
- fix_cli-0.4.0/server/reputation.py +142 -0
- fix_cli-0.4.0/server/store.py +154 -0
- fix_cli-0.4.0/setup.cfg +4 -0
- fix_cli-0.4.0/tests/test_agent.py +199 -0
- fix_cli-0.4.0/tests/test_app.py +899 -0
- fix_cli-0.4.0/tests/test_cli.py +54 -0
- fix_cli-0.4.0/tests/test_client.py +214 -0
- fix_cli-0.4.0/tests/test_contract.py +253 -0
- fix_cli-0.4.0/tests/test_crypto.py +114 -0
- fix_cli-0.4.0/tests/test_escrow.py +342 -0
- fix_cli-0.4.0/tests/test_judge.py +188 -0
- fix_cli-0.4.0/tests/test_nano.py +231 -0
- fix_cli-0.4.0/tests/test_reputation.py +189 -0
- fix_cli-0.4.0/tests/test_scrubber.py +409 -0
- fix_cli-0.4.0/tests/test_server.py +336 -0
fix_cli-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Karan Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fix_cli-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fix-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: AI-powered command fixer with contract-based dispute resolution
|
|
5
|
+
Author-email: Karan Sharma <karans4@protonmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: httpx>=0.24
|
|
11
|
+
Provides-Extra: server
|
|
12
|
+
Requires-Dist: fastapi>=0.100; extra == "server"
|
|
13
|
+
Requires-Dist: uvicorn>=0.20; extra == "server"
|
|
14
|
+
Requires-Dist: starlette>=0.27; extra == "server"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# fix
|
|
18
|
+
|
|
19
|
+
AI-powered command fixer. A command fails, an LLM diagnoses it, proposes a fix, and a contract system tracks the whole thing. Disputes go to an AI judge.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install git+https://github.com/karans4/fix.git
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Local mode (you need an API key)
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, or run Ollama
|
|
31
|
+
|
|
32
|
+
fix "gcc foo.c" # run command, fix if it fails
|
|
33
|
+
fix it # fix the last failed command
|
|
34
|
+
fix --explain "make" # just explain the error
|
|
35
|
+
fix --dry-run "make" # show fix without running
|
|
36
|
+
fix --local "make" # force Ollama (free, local)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Remote mode (free platform agent)
|
|
40
|
+
|
|
41
|
+
Post a contract to the platform. A free AI agent picks it up and proposes a fix.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
fix --remote "gcc foo.c"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Platform: `https://fix.notruefireman.org` (free during testing)
|
|
48
|
+
|
|
49
|
+
Configure in `~/.fix/config.py`:
|
|
50
|
+
```python
|
|
51
|
+
platform_url = "https://fix.notruefireman.org"
|
|
52
|
+
remote = True # default to remote mode
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Shell integration
|
|
56
|
+
|
|
57
|
+
For `fix it` / `fix !!` to work, add to your shell config:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
# bash/zsh
|
|
61
|
+
eval "$(fix shell)"
|
|
62
|
+
|
|
63
|
+
# fish
|
|
64
|
+
fix shell fish | source
|
|
65
|
+
|
|
66
|
+
# or auto-install
|
|
67
|
+
fix shell --install
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Safe mode (sandbox)
|
|
71
|
+
|
|
72
|
+
Default on Linux. Runs fixes in OverlayFS -- changes only committed if verification passes.
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
fix "make build" # sandbox on Linux by default
|
|
76
|
+
fix --no-safe "make" # skip sandbox
|
|
77
|
+
fix --safe "make" # force sandbox
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Verification
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
fix "gcc foo.c" # default: re-run, exit 0 = success
|
|
84
|
+
fix --verify=human "python3 render.py" # human judges
|
|
85
|
+
fix --verify="pytest tests/" "pip install x" # custom command
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
1. Command fails, stderr captured
|
|
91
|
+
2. Contract built (task, environment, verification terms, escrow)
|
|
92
|
+
3. Agent investigates (read-only commands), then proposes fix
|
|
93
|
+
4. Fix applied, verified mechanically
|
|
94
|
+
5. Multi-attempt: up to 3 tries, feeding failures back as context
|
|
95
|
+
6. Disputes go to an AI judge who reviews the full transcript
|
|
96
|
+
|
|
97
|
+
## Architecture
|
|
98
|
+
|
|
99
|
+
- `fix` -- CLI entry point
|
|
100
|
+
- `server/` -- FastAPI platform (contracts, escrow, reputation, judge)
|
|
101
|
+
- `protocol.py` -- state machine, constants
|
|
102
|
+
- `scrubber.py` -- redacts secrets from error output before sending to LLM
|
|
103
|
+
- `contract.py` -- builds structured contracts
|
|
104
|
+
- `client.py` / `agent.py` -- remote mode client and agent
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
fix_cli-0.4.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# fix
|
|
2
|
+
|
|
3
|
+
AI-powered command fixer. A command fails, an LLM diagnoses it, proposes a fix, and a contract system tracks the whole thing. Disputes go to an AI judge.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install git+https://github.com/karans4/fix.git
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### Local mode (you need an API key)
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, or run Ollama
|
|
15
|
+
|
|
16
|
+
fix "gcc foo.c" # run command, fix if it fails
|
|
17
|
+
fix it # fix the last failed command
|
|
18
|
+
fix --explain "make" # just explain the error
|
|
19
|
+
fix --dry-run "make" # show fix without running
|
|
20
|
+
fix --local "make" # force Ollama (free, local)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Remote mode (free platform agent)
|
|
24
|
+
|
|
25
|
+
Post a contract to the platform. A free AI agent picks it up and proposes a fix.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
fix --remote "gcc foo.c"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Platform: `https://fix.notruefireman.org` (free during testing)
|
|
32
|
+
|
|
33
|
+
Configure in `~/.fix/config.py`:
|
|
34
|
+
```python
|
|
35
|
+
platform_url = "https://fix.notruefireman.org"
|
|
36
|
+
remote = True # default to remote mode
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Shell integration
|
|
40
|
+
|
|
41
|
+
For `fix it` / `fix !!` to work, add to your shell config:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# bash/zsh
|
|
45
|
+
eval "$(fix shell)"
|
|
46
|
+
|
|
47
|
+
# fish
|
|
48
|
+
fix shell fish | source
|
|
49
|
+
|
|
50
|
+
# or auto-install
|
|
51
|
+
fix shell --install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Safe mode (sandbox)
|
|
55
|
+
|
|
56
|
+
Default on Linux. Runs fixes in OverlayFS -- changes only committed if verification passes.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
fix "make build" # sandbox on Linux by default
|
|
60
|
+
fix --no-safe "make" # skip sandbox
|
|
61
|
+
fix --safe "make" # force sandbox
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Verification
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
fix "gcc foo.c" # default: re-run, exit 0 = success
|
|
68
|
+
fix --verify=human "python3 render.py" # human judges
|
|
69
|
+
fix --verify="pytest tests/" "pip install x" # custom command
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
1. Command fails, stderr captured
|
|
75
|
+
2. Contract built (task, environment, verification terms, escrow)
|
|
76
|
+
3. Agent investigates (read-only commands), then proposes fix
|
|
77
|
+
4. Fix applied, verified mechanically
|
|
78
|
+
5. Multi-attempt: up to 3 tries, feeding failures back as context
|
|
79
|
+
6. Disputes go to an AI judge who reviews the full transcript
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
- `fix` -- CLI entry point
|
|
84
|
+
- `server/` -- FastAPI platform (contracts, escrow, reputation, judge)
|
|
85
|
+
- `protocol.py` -- state machine, constants
|
|
86
|
+
- `scrubber.py` -- redacts secrets from error output before sending to LLM
|
|
87
|
+
- `contract.py` -- builds structured contracts
|
|
88
|
+
- `client.py` / `agent.py` -- remote mode client and agent
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
fix_cli-0.4.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
|
fix_cli-0.4.0/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}")
|