taskproof 0.3.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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""taskproof — a conveyor belt for AI coding agents.
|
|
2
|
+
|
|
3
|
+
It does not decide what to do. It guarantees that what was dispatched gets
|
|
4
|
+
independently verified and recorded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
# The single source of the version: `pyproject.toml` reads it from here
|
|
10
|
+
# (`[tool.setuptools.dynamic]`), so cutting a release bumps exactly one string.
|
|
11
|
+
__version__ = "0.3.0"
|
|
12
|
+
|
|
13
|
+
# Fail early and in plain language. Without this, running from a source checkout
|
|
14
|
+
# under an older interpreter surfaces as `ModuleNotFoundError: No module named
|
|
15
|
+
# 'tomllib'` from somewhere deep in the registry loader — which says nothing
|
|
16
|
+
# about the real problem (the interpreter is too old).
|
|
17
|
+
if sys.version_info < (3, 11):
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"taskproof requires Python 3.11 or newer: the project registry is parsed "
|
|
20
|
+
f"with the standard library's tomllib. This interpreter is {sys.version.split()[0]}. "
|
|
21
|
+
"Use a newer Python, or install the packaged version with "
|
|
22
|
+
"`pipx install taskproof` (which will refuse incompatible interpreters for you)."
|
|
23
|
+
)
|
taskproof/__main__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Adapter registry.
|
|
2
|
+
|
|
3
|
+
`get(name)` resolves a built-in adapter or a `custom:<cmd>` fallback.
|
|
4
|
+
|
|
5
|
+
The resolution order matters: a registry entry saying `custom:my-agent --flag`
|
|
6
|
+
always wins over any built-in of the same prefix, and an unknown bare name is a
|
|
7
|
+
usage error rather than a silent fallback — silently running the wrong agent is
|
|
8
|
+
exactly the class of failure this project exists to prevent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import Dict, List, Type
|
|
14
|
+
|
|
15
|
+
from ..errors import UsageError
|
|
16
|
+
from .base import Adapter
|
|
17
|
+
from .custom import CustomAdapter
|
|
18
|
+
|
|
19
|
+
_BUILTINS: Dict[str, str] = {
|
|
20
|
+
# name -> module attribute path, resolved lazily in get()
|
|
21
|
+
"codex": "codex",
|
|
22
|
+
"claude": "claude",
|
|
23
|
+
"gemini": "gemini",
|
|
24
|
+
"opencode": "opencode",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#: Every adapter name this build knows about (used by doctor / --help).
|
|
28
|
+
KNOWN = tuple(sorted(_BUILTINS)) + ("custom:<cmd>",)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_class(module_name: str) -> Type[Adapter]:
|
|
32
|
+
"""Import `taskproof.adapters.<module_name>` and return its Adapter class.
|
|
33
|
+
|
|
34
|
+
Kept as a function so `get()` can resolve built-ins lazily: importing a
|
|
35
|
+
module never executes the CLI it wraps.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
module = importlib.import_module(f".{module_name}", __package__)
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise UsageError(
|
|
41
|
+
f"adapter module not available: {module_name} ({exc})",
|
|
42
|
+
hint=f"known adapters: {', '.join(KNOWN)}",
|
|
43
|
+
)
|
|
44
|
+
candidates = [
|
|
45
|
+
obj
|
|
46
|
+
for obj in vars(module).values()
|
|
47
|
+
if isinstance(obj, type)
|
|
48
|
+
and issubclass(obj, Adapter)
|
|
49
|
+
and obj is not Adapter
|
|
50
|
+
and obj.__module__ == module.__name__
|
|
51
|
+
]
|
|
52
|
+
if not candidates:
|
|
53
|
+
raise UsageError(f"adapter module '{module_name}' defines no Adapter subclass")
|
|
54
|
+
# Deterministic: prefer the conventional `<Name>Adapter` class.
|
|
55
|
+
candidates.sort(key=lambda cls: (not cls.__name__.endswith("Adapter"), cls.__name__))
|
|
56
|
+
return candidates[0]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def available() -> List[str]:
|
|
60
|
+
"""Names whose CLI is actually present on PATH (does not execute them)."""
|
|
61
|
+
found: List[str] = []
|
|
62
|
+
for name, module_name in _BUILTINS.items():
|
|
63
|
+
try:
|
|
64
|
+
adapter_cls = load_class(module_name)
|
|
65
|
+
except UsageError:
|
|
66
|
+
continue
|
|
67
|
+
binary = getattr(adapter_cls, "binary", "") or name
|
|
68
|
+
if shutil.which(binary):
|
|
69
|
+
found.append(name)
|
|
70
|
+
return found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get(name, **kwargs) -> Adapter:
|
|
74
|
+
"""Instantiate an adapter by name.
|
|
75
|
+
|
|
76
|
+
`name` may be:
|
|
77
|
+
* a built-in name (`codex`, `claude`, ...)
|
|
78
|
+
* `custom:<shell command>` — the command is templated, not executed here
|
|
79
|
+
|
|
80
|
+
Unknown bare names raise UsageError (exit 64).
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(name, str) or not name.strip():
|
|
83
|
+
raise UsageError("adapter name is required")
|
|
84
|
+
name = name.strip()
|
|
85
|
+
|
|
86
|
+
if name.startswith("custom:"):
|
|
87
|
+
command = name[len("custom:"):].strip()
|
|
88
|
+
if not command:
|
|
89
|
+
raise UsageError(
|
|
90
|
+
"custom adapter needs a command",
|
|
91
|
+
hint="usage: custom:<command template with {prompt} / {workdir}>",
|
|
92
|
+
)
|
|
93
|
+
return CustomAdapter(command, **kwargs)
|
|
94
|
+
|
|
95
|
+
if name in _BUILTINS:
|
|
96
|
+
return load_class(_BUILTINS[name])(**kwargs)
|
|
97
|
+
|
|
98
|
+
raise UsageError(
|
|
99
|
+
f"unknown adapter: {name}",
|
|
100
|
+
hint=f"known adapters: {', '.join(KNOWN)}",
|
|
101
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Adapter contract.
|
|
2
|
+
|
|
3
|
+
An adapter is one command template plus one result-parsing convention. It knows
|
|
4
|
+
how to invoke a coding agent CLI and how to salvage a structured outcome from
|
|
5
|
+
its output. It knows nothing about concurrency, verification or storage.
|
|
6
|
+
|
|
7
|
+
The contract exists because agent CLIs lie, crash, and wrap their output in
|
|
8
|
+
markdown. Documented incidents that shaped it:
|
|
9
|
+
|
|
10
|
+
* result JSON wrapped in a ```json fence -> structured parse failed and the
|
|
11
|
+
whole run looked like it had done nothing
|
|
12
|
+
* "I verified this in the browser, screenshot attached" with zero browser
|
|
13
|
+
activity in the event stream -> hence: the adapter's summary is reported,
|
|
14
|
+
never trusted
|
|
15
|
+
* an out-of-scope HTTP login attempt -> hence: `forbidden_paths` are checked
|
|
16
|
+
after the run, and adapters are invoked in the narrowest sandbox they offer
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from ..models import AdapterResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResultParseError(Exception):
|
|
28
|
+
"""The adapter ran but its result could not be understood."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Adapter(ABC):
|
|
32
|
+
"""Base class for agent CLI adapters."""
|
|
33
|
+
|
|
34
|
+
#: Name used in the registry and on the command line.
|
|
35
|
+
name: str = ""
|
|
36
|
+
|
|
37
|
+
#: Executable looked up on PATH by `adapters.available()` / `preflight()`.
|
|
38
|
+
binary: str = ""
|
|
39
|
+
|
|
40
|
+
#: True when the CLI can be constrained to a machine-readable result.
|
|
41
|
+
supports_schema = False
|
|
42
|
+
|
|
43
|
+
#: True when the CLI offers a sandbox / approval mode worth using.
|
|
44
|
+
supports_sandbox = False
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, model: Optional[str] = None, reasoning: Optional[str] = None,
|
|
47
|
+
timeout: int = 1800, env: Optional[Dict[str, str]] = None):
|
|
48
|
+
self.model = model
|
|
49
|
+
self.reasoning = reasoning
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
self.env = env or {}
|
|
52
|
+
|
|
53
|
+
# -- required ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def build_command(self, *, brief: str, workdir: str,
|
|
57
|
+
schema_path: Optional[str] = None,
|
|
58
|
+
read_only: bool = False) -> list:
|
|
59
|
+
"""Return argv for one invocation.
|
|
60
|
+
|
|
61
|
+
Must not include a shell: pass a list, never `shell=True`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
66
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
67
|
+
"""Normalise the CLI's outcome.
|
|
68
|
+
|
|
69
|
+
Implementations MUST tolerate:
|
|
70
|
+
* a markdown code fence around the JSON payload
|
|
71
|
+
* a missing or empty result file
|
|
72
|
+
* trailing noise on stdout after the payload
|
|
73
|
+
|
|
74
|
+
Degrading to a summary parsed from stdout is allowed and must set
|
|
75
|
+
`parse_degraded=True`. Raising ResultParseError is allowed, and must be
|
|
76
|
+
surfaced as an adapter failure (exit 70) — never silently treated as
|
|
77
|
+
success.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# -- optional ---------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def preflight(self) -> Optional[str]:
|
|
83
|
+
"""Return a human-readable problem if this CLI is unusable, else None.
|
|
84
|
+
|
|
85
|
+
Used by `taskproof doctor`. Must not execute the agent.
|
|
86
|
+
"""
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
#: Extra context appended to the brief, per CLI.
|
|
90
|
+
prompt_preamble: str = ""
|
|
91
|
+
|
|
92
|
+
def full_prompt(self, brief: str) -> str:
|
|
93
|
+
return f"{self.prompt_preamble}{brief}" if self.prompt_preamble else brief
|
|
94
|
+
|
|
95
|
+
def degraded_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
96
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
97
|
+
"""Stdout-only fallback shared by adapters without structured output.
|
|
98
|
+
|
|
99
|
+
Salvages a JSON object from stdout when possible, otherwise keeps the
|
|
100
|
+
tail of the text as the summary. ALWAYS sets `parse_degraded=True`
|
|
101
|
+
because nothing here is schema-constrained. Raises `ResultParseError`
|
|
102
|
+
when there is nothing at all to salvage (the caller must treat that as
|
|
103
|
+
an adapter failure, exit 70 — never a silent success).
|
|
104
|
+
"""
|
|
105
|
+
if not stdout or not stdout.strip():
|
|
106
|
+
raise ResultParseError(
|
|
107
|
+
f"{self.name}: produced no output to parse"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
decoded = extract_json(stdout)
|
|
111
|
+
except ResultParseError:
|
|
112
|
+
decoded = None
|
|
113
|
+
payload = decoded if isinstance(decoded, dict) else None
|
|
114
|
+
return AdapterResult(
|
|
115
|
+
exit_code=exit_code,
|
|
116
|
+
summary=_summarize(decoded, stdout),
|
|
117
|
+
payload=payload,
|
|
118
|
+
raw_stdout=stdout,
|
|
119
|
+
raw_stderr=stderr,
|
|
120
|
+
parse_degraded=True,
|
|
121
|
+
result_path=result_path,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# JSON recovery
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
_FENCE_RE = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n?(.*?)```", re.DOTALL)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_json(text: str) -> Any:
|
|
133
|
+
"""Best-effort JSON recovery used by adapters.
|
|
134
|
+
|
|
135
|
+
Order of attempts:
|
|
136
|
+
1. the whole string parses
|
|
137
|
+
2. a fenced block (```json ... ``` or ``` ... ```) parses
|
|
138
|
+
3. the first balanced `{...}` object parses
|
|
139
|
+
|
|
140
|
+
Returns the decoded object, or raises ResultParseError.
|
|
141
|
+
"""
|
|
142
|
+
if text is None:
|
|
143
|
+
raise ResultParseError("no result to parse")
|
|
144
|
+
stripped = text.strip()
|
|
145
|
+
if not stripped:
|
|
146
|
+
raise ResultParseError("empty result")
|
|
147
|
+
|
|
148
|
+
# 1. the whole string is JSON
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(stripped)
|
|
151
|
+
except (json.JSONDecodeError, ValueError):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
# 2. a fenced block
|
|
155
|
+
for match in _FENCE_RE.finditer(stripped):
|
|
156
|
+
block = match.group(2).strip()
|
|
157
|
+
if not block:
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(block)
|
|
161
|
+
except (json.JSONDecodeError, ValueError):
|
|
162
|
+
pass
|
|
163
|
+
balanced = _first_balanced_object(block)
|
|
164
|
+
if balanced is not _MISS:
|
|
165
|
+
return balanced
|
|
166
|
+
|
|
167
|
+
# 3. the first balanced {...} object anywhere in the string
|
|
168
|
+
balanced = _first_balanced_object(stripped)
|
|
169
|
+
if balanced is not _MISS:
|
|
170
|
+
return balanced
|
|
171
|
+
|
|
172
|
+
raise ResultParseError("could not extract JSON from result")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Missing:
|
|
176
|
+
"""Sentinel so `None` / `0` / `False` are still valid decoded values."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_MISS = _Missing()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _first_balanced_object(text: str):
|
|
183
|
+
"""Decode the first brace-balanced `{...}` object, or return `_MISS`.
|
|
184
|
+
|
|
185
|
+
String literals and escapes are respected so a `}` inside a JSON string
|
|
186
|
+
does not close the object early.
|
|
187
|
+
"""
|
|
188
|
+
start = text.find("{")
|
|
189
|
+
while start != -1:
|
|
190
|
+
depth = 0
|
|
191
|
+
in_string = False
|
|
192
|
+
escaped = False
|
|
193
|
+
for index in range(start, len(text)):
|
|
194
|
+
char = text[index]
|
|
195
|
+
if in_string:
|
|
196
|
+
if escaped:
|
|
197
|
+
escaped = False
|
|
198
|
+
elif char == "\\":
|
|
199
|
+
escaped = True
|
|
200
|
+
elif char == '"':
|
|
201
|
+
in_string = False
|
|
202
|
+
else:
|
|
203
|
+
if char == '"':
|
|
204
|
+
in_string = True
|
|
205
|
+
elif char == "{":
|
|
206
|
+
depth += 1
|
|
207
|
+
elif char == "}":
|
|
208
|
+
depth -= 1
|
|
209
|
+
if depth == 0:
|
|
210
|
+
candidate = text[start:index + 1]
|
|
211
|
+
try:
|
|
212
|
+
return json.loads(candidate)
|
|
213
|
+
except (json.JSONDecodeError, ValueError):
|
|
214
|
+
break # start over from the next '{'
|
|
215
|
+
start = text.find("{", start + 1)
|
|
216
|
+
return _MISS
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _summarize(payload: Any, text: str, tail: int = 40) -> str:
|
|
220
|
+
"""Human-readable one-liner: prefer a summary field, else tail the text."""
|
|
221
|
+
if isinstance(payload, dict):
|
|
222
|
+
for key in ("summary", "message", "result", "text"):
|
|
223
|
+
value = payload.get(key)
|
|
224
|
+
if isinstance(value, str) and value.strip():
|
|
225
|
+
return value.strip()
|
|
226
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
227
|
+
lines: List[str] = [line for line in text.splitlines() if line.strip()]
|
|
228
|
+
return "\n".join(lines[-tail:])
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Claude Code CLI adapter — NOT tested on this machine (claude not installed).
|
|
2
|
+
|
|
3
|
+
Command shape (print mode, JSON output):
|
|
4
|
+
|
|
5
|
+
claude -p "<prompt>" [--model <m>] [--permission-mode plan|acceptEdits]
|
|
6
|
+
|
|
7
|
+
`read_only=True` maps to `--permission-mode plan`, the non-mutating mode.
|
|
8
|
+
Result parsing degrades to stdout: `parse_degraded=True` always. The Claude
|
|
9
|
+
`--output-format json` envelope is not a JSON Schema, so `supports_schema`
|
|
10
|
+
stays False.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from ..models import AdapterResult
|
|
17
|
+
from .base import Adapter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClaudeAdapter(Adapter):
|
|
21
|
+
name = "claude"
|
|
22
|
+
binary = "claude"
|
|
23
|
+
supports_schema = False
|
|
24
|
+
supports_sandbox = True
|
|
25
|
+
|
|
26
|
+
def build_command(self, *, brief: str, workdir: str,
|
|
27
|
+
schema_path: Optional[str] = None,
|
|
28
|
+
read_only: bool = False) -> list:
|
|
29
|
+
argv = ["claude", "-p", self.full_prompt(brief)]
|
|
30
|
+
if self.model:
|
|
31
|
+
argv += ["--model", self.model]
|
|
32
|
+
# read-only is the "plan" permission mode (no edits).
|
|
33
|
+
argv += ["--permission-mode", "plan" if read_only else "acceptEdits"]
|
|
34
|
+
return argv
|
|
35
|
+
|
|
36
|
+
def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
37
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
38
|
+
return self.degraded_result(
|
|
39
|
+
exit_code=exit_code, stdout=stdout, stderr=stderr,
|
|
40
|
+
result_path=result_path,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def preflight(self) -> Optional[str]:
|
|
44
|
+
if shutil.which(self.binary) is None:
|
|
45
|
+
return "claude CLI not found on PATH (adapter not tested on this machine)"
|
|
46
|
+
return None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Codex CLI adapter — the one built-in tested on this machine.
|
|
2
|
+
|
|
3
|
+
Verified against `codex-cli 0.160.0`. The relevant lines from `codex exec
|
|
4
|
+
--help` are:
|
|
5
|
+
|
|
6
|
+
Usage: codex exec [OPTIONS] [PROMPT]
|
|
7
|
+
-m, --model <MODEL>
|
|
8
|
+
-c, --config <key=value>
|
|
9
|
+
-s, --sandbox <SANDBOX_MODE>
|
|
10
|
+
[possible values: read-only, workspace-write, danger-full-access]
|
|
11
|
+
-C, --cd <DIR>
|
|
12
|
+
--output-schema <FILE> Path to a JSON Schema file describing the model's
|
|
13
|
+
final response shape
|
|
14
|
+
-o, --output-last-message <FILE>
|
|
15
|
+
Specifies file where the last message from the
|
|
16
|
+
agent should be written
|
|
17
|
+
|
|
18
|
+
Command shape (from the real dispatch scripts):
|
|
19
|
+
|
|
20
|
+
codex exec -C <workdir> --sandbox read-only|workspace-write \\
|
|
21
|
+
--output-schema <schema.json> -o <result.json> \\
|
|
22
|
+
[--model <m>] [-c model_reasoning_effort=<r>] "<prompt>"
|
|
23
|
+
|
|
24
|
+
The result file named by `-o` is read first; if it is missing (or holds no
|
|
25
|
+
JSON) the adapter degrades to scraping stdout and sets `parse_degraded=True`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import tempfile
|
|
31
|
+
from typing import List, Optional
|
|
32
|
+
|
|
33
|
+
from ..models import AdapterResult
|
|
34
|
+
from .base import Adapter, ResultParseError, _summarize, extract_json
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodexAdapter(Adapter):
|
|
38
|
+
name = "codex"
|
|
39
|
+
binary = "codex"
|
|
40
|
+
supports_schema = True
|
|
41
|
+
supports_sandbox = True
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, model: Optional[str] = None, reasoning: Optional[str] = None,
|
|
44
|
+
timeout: int = 1800, env: Optional[dict] = None,
|
|
45
|
+
result_path: Optional[str] = None):
|
|
46
|
+
super().__init__(model=model, reasoning=reasoning, timeout=timeout, env=env)
|
|
47
|
+
#: Where `codex -o` writes the last message. Callers may set it; when
|
|
48
|
+
#: left None `build_command` allocates a per-run temp file.
|
|
49
|
+
self.result_path = result_path
|
|
50
|
+
self._seq = 0
|
|
51
|
+
|
|
52
|
+
def _allocate_result_path(self) -> str:
|
|
53
|
+
self._seq += 1
|
|
54
|
+
return os.path.join(
|
|
55
|
+
tempfile.gettempdir(),
|
|
56
|
+
f"taskproof-codex-{os.getpid()}-{self._seq}.json",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def build_command(self, *, brief: str, workdir: str,
|
|
60
|
+
schema_path: Optional[str] = None,
|
|
61
|
+
read_only: bool = False) -> list:
|
|
62
|
+
if self.result_path is None:
|
|
63
|
+
self.result_path = self._allocate_result_path()
|
|
64
|
+
|
|
65
|
+
# argv list, never a shell string: the prompt is passed as one element
|
|
66
|
+
# and can never inject a `;`/`&&`/backtick into a shell.
|
|
67
|
+
argv: List[str] = ["codex", "exec"]
|
|
68
|
+
argv += ["-C", workdir]
|
|
69
|
+
# read-only vs workspace-write is the whole point of the sandbox flag.
|
|
70
|
+
argv += ["--sandbox", "read-only" if read_only else "workspace-write"]
|
|
71
|
+
if schema_path:
|
|
72
|
+
argv += ["--output-schema", schema_path]
|
|
73
|
+
argv += ["-o", self.result_path]
|
|
74
|
+
if self.model:
|
|
75
|
+
argv += ["--model", self.model]
|
|
76
|
+
if self.reasoning:
|
|
77
|
+
argv += ["-c", f"model_reasoning_effort={self.reasoning}"]
|
|
78
|
+
argv += [self.full_prompt(brief)]
|
|
79
|
+
return argv
|
|
80
|
+
|
|
81
|
+
def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
82
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
83
|
+
path = result_path or self.result_path
|
|
84
|
+
|
|
85
|
+
payload = None
|
|
86
|
+
raw = ""
|
|
87
|
+
if path and os.path.isfile(path):
|
|
88
|
+
try:
|
|
89
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
90
|
+
raw = handle.read()
|
|
91
|
+
except OSError:
|
|
92
|
+
raw = ""
|
|
93
|
+
if raw.strip():
|
|
94
|
+
try:
|
|
95
|
+
payload = extract_json(raw)
|
|
96
|
+
except ResultParseError:
|
|
97
|
+
payload = None
|
|
98
|
+
|
|
99
|
+
if payload is not None:
|
|
100
|
+
# A structured result came back from the `-o` file. A fenced file is
|
|
101
|
+
# still the structured result (extract_json unwraps it), so this is
|
|
102
|
+
# NOT flagged as degraded.
|
|
103
|
+
return AdapterResult(
|
|
104
|
+
exit_code=exit_code,
|
|
105
|
+
summary=_summarize(payload, raw),
|
|
106
|
+
payload=payload if isinstance(payload, dict) else None,
|
|
107
|
+
raw_stdout=stdout,
|
|
108
|
+
raw_stderr=stderr,
|
|
109
|
+
parse_degraded=False,
|
|
110
|
+
result_path=path,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Result file missing/empty/unparseable -> salvage stdout if we can.
|
|
114
|
+
if stdout and stdout.strip():
|
|
115
|
+
decoded = None
|
|
116
|
+
try:
|
|
117
|
+
decoded = extract_json(stdout)
|
|
118
|
+
except ResultParseError:
|
|
119
|
+
decoded = None
|
|
120
|
+
return AdapterResult(
|
|
121
|
+
exit_code=exit_code,
|
|
122
|
+
summary=_summarize(decoded, stdout),
|
|
123
|
+
payload=decoded if isinstance(decoded, dict) else None,
|
|
124
|
+
raw_stdout=stdout,
|
|
125
|
+
raw_stderr=stderr,
|
|
126
|
+
parse_degraded=True,
|
|
127
|
+
result_path=path,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
raise ResultParseError(
|
|
131
|
+
f"codex: no parseable result (result_path={path!r}, empty stdout)"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def preflight(self) -> Optional[str]:
|
|
135
|
+
if shutil.which(self.binary) is None:
|
|
136
|
+
return (
|
|
137
|
+
"codex CLI not found on PATH — install codex-cli, or use "
|
|
138
|
+
"custom:<cmd> pointing at your agent"
|
|
139
|
+
)
|
|
140
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Custom command adapter — `custom:<command template>`.
|
|
2
|
+
|
|
3
|
+
The template is tokenised with `shlex.split` (shell-style quoting, but no
|
|
4
|
+
shell is ever spawned). Two placeholders are substituted, per token, AFTER
|
|
5
|
+
tokenisation so a prompt containing spaces/quotes stays a single argv element
|
|
6
|
+
and cannot be re-interpreted as shell syntax:
|
|
7
|
+
|
|
8
|
+
{prompt} the full brief (adapter `prompt_preamble` included)
|
|
9
|
+
{workdir} the directory the task runs in
|
|
10
|
+
|
|
11
|
+
Example registry value:
|
|
12
|
+
|
|
13
|
+
custom:my-agent --cwd {workdir} --task {prompt}
|
|
14
|
+
|
|
15
|
+
There is no structured-output convention for an arbitrary command, so
|
|
16
|
+
`supports_schema = False` and `parse_result` always degrades: it salvages a
|
|
17
|
+
JSON object from stdout when present, otherwise keeps the tail of the text,
|
|
18
|
+
and sets `parse_degraded=True`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import shlex
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..models import AdapterResult
|
|
25
|
+
from .base import Adapter
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CustomAdapter(Adapter):
|
|
29
|
+
name = "custom"
|
|
30
|
+
supports_schema = False
|
|
31
|
+
supports_sandbox = False
|
|
32
|
+
|
|
33
|
+
def __init__(self, command: str = "", *, model: Optional[str] = None,
|
|
34
|
+
reasoning: Optional[str] = None, timeout: int = 1800,
|
|
35
|
+
env: Optional[dict] = None):
|
|
36
|
+
super().__init__(model=model, reasoning=reasoning, timeout=timeout, env=env)
|
|
37
|
+
#: The raw template (everything after the `custom:` prefix).
|
|
38
|
+
self.command = command
|
|
39
|
+
|
|
40
|
+
def build_command(self, *, brief: str, workdir: str,
|
|
41
|
+
schema_path: Optional[str] = None,
|
|
42
|
+
read_only: bool = False) -> list:
|
|
43
|
+
# `schema_path` / `read_only` are not expressible for an arbitrary
|
|
44
|
+
# command; they are accepted for interface compatibility and ignored.
|
|
45
|
+
prompt = self.full_prompt(brief)
|
|
46
|
+
argv = []
|
|
47
|
+
for token in shlex.split(self.command):
|
|
48
|
+
argv.append(token.replace("{workdir}", workdir).replace("{prompt}", prompt))
|
|
49
|
+
return argv
|
|
50
|
+
|
|
51
|
+
def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
52
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
53
|
+
return self.degraded_result(
|
|
54
|
+
exit_code=exit_code, stdout=stdout, stderr=stderr,
|
|
55
|
+
result_path=result_path,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def preflight(self) -> Optional[str]:
|
|
59
|
+
# An arbitrary command cannot be validated without running it (which
|
|
60
|
+
# we never do here). Report nothing; `doctor` shows the template.
|
|
61
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Gemini CLI adapter — NOT tested on this machine (gemini not installed).
|
|
2
|
+
|
|
3
|
+
Command shape (non-interactive prompt):
|
|
4
|
+
|
|
5
|
+
gemini -p "<prompt>" [-m <model>] [--approval-mode plan|auto_edit]
|
|
6
|
+
|
|
7
|
+
`read_only=True` maps to `--approval-mode plan`. No structured schema output
|
|
8
|
+
is assumed, so `supports_schema = False`; parsing degrades to stdout and sets
|
|
9
|
+
`parse_degraded=True` always.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ..models import AdapterResult
|
|
16
|
+
from .base import Adapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GeminiAdapter(Adapter):
|
|
20
|
+
name = "gemini"
|
|
21
|
+
binary = "gemini"
|
|
22
|
+
supports_schema = False
|
|
23
|
+
supports_sandbox = True
|
|
24
|
+
|
|
25
|
+
def build_command(self, *, brief: str, workdir: str,
|
|
26
|
+
schema_path: Optional[str] = None,
|
|
27
|
+
read_only: bool = False) -> list:
|
|
28
|
+
argv = ["gemini", "-p", self.full_prompt(brief)]
|
|
29
|
+
if self.model:
|
|
30
|
+
argv += ["-m", self.model]
|
|
31
|
+
# `plan` approval mode refuses side effects (read-only).
|
|
32
|
+
argv += ["--approval-mode", "plan" if read_only else "auto_edit"]
|
|
33
|
+
return argv
|
|
34
|
+
|
|
35
|
+
def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
|
|
36
|
+
result_path: Optional[str]) -> AdapterResult:
|
|
37
|
+
return self.degraded_result(
|
|
38
|
+
exit_code=exit_code, stdout=stdout, stderr=stderr,
|
|
39
|
+
result_path=result_path,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def preflight(self) -> Optional[str]:
|
|
43
|
+
if shutil.which(self.binary) is None:
|
|
44
|
+
return "gemini CLI not found on PATH (adapter not tested on this machine)"
|
|
45
|
+
return None
|