subtask-mcp 0.2.3__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.
- subtask/__init__.py +3 -0
- subtask/admission.py +254 -0
- subtask/capacity.py +90 -0
- subtask/cli.py +183 -0
- subtask/config.py +59 -0
- subtask/core.py +603 -0
- subtask/doctor.py +120 -0
- subtask/engines/__init__.py +1 -0
- subtask/engines/base.py +297 -0
- subtask/engines/claude_api.py +204 -0
- subtask/engines/codex.py +182 -0
- subtask/engines/demo.py +50 -0
- subtask/engines/dsh.py +96 -0
- subtask/engines/kimi.py +75 -0
- subtask/engines/opencode.py +87 -0
- subtask/engines/registry.py +124 -0
- subtask/events.py +105 -0
- subtask/evidence.py +103 -0
- subtask/fastserver.py +272 -0
- subtask/gc.py +99 -0
- subtask/install/__init__.py +1 -0
- subtask/install/clients.py +355 -0
- subtask/install/commands.py +122 -0
- subtask/install/doctor.py +112 -0
- subtask/install/fileops.py +69 -0
- subtask/install/installer.py +204 -0
- subtask/install/ownership.py +44 -0
- subtask/install/tomledit.py +44 -0
- subtask/jobs.py +256 -0
- subtask/ledger.py +268 -0
- subtask/narrate.py +134 -0
- subtask/policy.py +58 -0
- subtask/procid.py +97 -0
- subtask/progress.py +152 -0
- subtask/redact.py +61 -0
- subtask/roi.py +84 -0
- subtask/router.py +202 -0
- subtask/runners.py +99 -0
- subtask/schemacheck.py +131 -0
- subtask/server.py +192 -0
- subtask/skill/SKILL.md +96 -0
- subtask/telemetry.py +76 -0
- subtask/tools.json +237 -0
- subtask/worker.py +278 -0
- subtask_mcp-0.2.3.dist-info/METADATA +164 -0
- subtask_mcp-0.2.3.dist-info/RECORD +50 -0
- subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
- subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
- subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
- subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
subtask/doctor.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Diagnostics without spending: platform, tools, engines, state, protocol.
|
|
2
|
+
|
|
3
|
+
Each check says what was checked and how: installed, configured and
|
|
4
|
+
authenticated are different facts, and quota or sandbox availability are
|
|
5
|
+
reported separately. Nothing here calls a paid API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__, config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check(name: str, ok: bool | None, detail: str) -> dict:
|
|
21
|
+
return {"check": name, "ok": ok, "detail": detail}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def engines() -> list[dict]:
|
|
25
|
+
from .engines import registry
|
|
26
|
+
|
|
27
|
+
out = []
|
|
28
|
+
for name, adapter in registry.all_engines().items():
|
|
29
|
+
probe = adapter.probe()
|
|
30
|
+
out.append(
|
|
31
|
+
{
|
|
32
|
+
"engine": name,
|
|
33
|
+
"installed": probe.installed,
|
|
34
|
+
"version": probe.version,
|
|
35
|
+
"compatible": probe.compatible,
|
|
36
|
+
"auth": probe.auth,
|
|
37
|
+
"problems": probe.problems,
|
|
38
|
+
"capabilities": adapter.capabilities.as_dict(),
|
|
39
|
+
"capacity": adapter.capacity().as_dict() if probe.installed else None,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
for err in registry.plugin_errors():
|
|
43
|
+
out.append({"engine": "plugin", "installed": False, "problems": [err]})
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bwrap() -> bool:
|
|
48
|
+
from .runners import bwrap_available
|
|
49
|
+
|
|
50
|
+
return bwrap_available()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def checks() -> list[dict]:
|
|
54
|
+
out = [
|
|
55
|
+
_check("subtask", True, f"{__version__} on Python {platform.python_version()} ({sys.executable})"),
|
|
56
|
+
_check(
|
|
57
|
+
"platform",
|
|
58
|
+
sys.platform.startswith("linux") or None,
|
|
59
|
+
f"{platform.system()} {platform.release()}: Linux supported; macOS degraded (no /proc start times, "
|
|
60
|
+
"no bwrap); Windows not supported (fcntl)",
|
|
61
|
+
),
|
|
62
|
+
_check("git", bool(shutil.which("git")), shutil.which("git") or "git not found: repo= jobs need it"),
|
|
63
|
+
_check(
|
|
64
|
+
"bwrap",
|
|
65
|
+
bool(shutil.which("bwrap")) or None,
|
|
66
|
+
"available: policy=isolated works"
|
|
67
|
+
if shutil.which("bwrap")
|
|
68
|
+
else "missing: policy=isolated refused",
|
|
69
|
+
),
|
|
70
|
+
_check("socat", bool(shutil.which("socat")) or None, "Claude Code sandbox network proxy on Linux"),
|
|
71
|
+
]
|
|
72
|
+
env = config.ENV_FILE
|
|
73
|
+
if env.is_file():
|
|
74
|
+
mode = stat.S_IMODE(env.stat().st_mode)
|
|
75
|
+
out.append(_check("secret file", mode & 0o077 == 0, f"{env} mode {oct(mode)} (want 600)"))
|
|
76
|
+
else:
|
|
77
|
+
out.append(_check("secret file", None, f"{env} missing: *-api engines unavailable"))
|
|
78
|
+
try:
|
|
79
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
writable = os.access(config.HOME, os.W_OK)
|
|
81
|
+
except OSError:
|
|
82
|
+
writable = False
|
|
83
|
+
out.append(_check("state dir", writable, str(config.HOME)))
|
|
84
|
+
try:
|
|
85
|
+
from . import admission, ledger
|
|
86
|
+
|
|
87
|
+
out.append(
|
|
88
|
+
_check("active jobs", True, f"{len(admission.active())} of {config.MAX_RUNNING} slots used")
|
|
89
|
+
)
|
|
90
|
+
out.append(
|
|
91
|
+
_check("spend", True, f"{ledger.committed():.2f}$ committed of {config.MONTHLY_CAP_USD:.0f}$")
|
|
92
|
+
)
|
|
93
|
+
except Exception as exc: # noqa: BLE001
|
|
94
|
+
out.append(_check("state", False, f"{type(exc).__name__}: {exc}"))
|
|
95
|
+
out.append(
|
|
96
|
+
_check(
|
|
97
|
+
"mcp protocol", True, "dual-era: 2026-07-28 (stateless) and 2025-11-25..2024-11-05 (initialize)"
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def report(as_json: bool = False) -> str:
|
|
104
|
+
data = {"checks": checks(), "engines": engines()}
|
|
105
|
+
if as_json:
|
|
106
|
+
return json.dumps(data, indent=1, default=str)
|
|
107
|
+
mark = {True: "ok ", False: "BAD", None: "-- "}
|
|
108
|
+
lines = [f"{mark[c['ok']]} {c['check']}: {c['detail']}" for c in data["checks"]]
|
|
109
|
+
for e in data["engines"]:
|
|
110
|
+
if e["engine"] == "plugin":
|
|
111
|
+
lines.append(f"BAD plugin: {'; '.join(e['problems'])}")
|
|
112
|
+
continue
|
|
113
|
+
state = "ok " if e["installed"] and e["compatible"] else "-- " if not e["installed"] else "BAD"
|
|
114
|
+
cap = e.get("capacity") or {}
|
|
115
|
+
free = "?" if cap.get("value") is None else f"{cap['value']:.0f}%"
|
|
116
|
+
lines.append(
|
|
117
|
+
f"{state} {e['engine']:12} {e['version'] or 'not installed'} · auth {e['auth']} · free {free} "
|
|
118
|
+
f"({cap.get('confidence', 'none')}) {'; '.join(e['problems'])}"[:200]
|
|
119
|
+
)
|
|
120
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine adapters. `registry.get(name)` returns one; the core knows no provider flags."""
|
subtask/engines/base.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Engine adapter protocol: what the core may ask of an engine, and nothing about MCP or chat.
|
|
2
|
+
|
|
3
|
+
An adapter declares capabilities, probes the local install, reads capacity,
|
|
4
|
+
and runs one attempt. Third-party adapters register through the
|
|
5
|
+
`subtask.engines` entry point group (see registry.py); the core never changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
ADAPTER_API = 1 # bump when the protocol below changes incompatibly
|
|
20
|
+
|
|
21
|
+
# Security levels, weakest first. A job asks for a minimum; adapters list what they enforce.
|
|
22
|
+
LEVELS = ("worktree", "sandbox", "isolated")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Factory:
|
|
26
|
+
def __init__(self, make: Callable[[], object]) -> None:
|
|
27
|
+
self.make = make
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def field(default_factory: Callable[[], object]) -> object:
|
|
31
|
+
return _Factory(default_factory)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record(cls: type) -> type:
|
|
35
|
+
"""A tiny stand-in for @dataclass: `dataclasses` pulls in `inspect` (~10 ms per cold start)."""
|
|
36
|
+
names = list(cls.__annotations__) # own annotations (strings: postponed evaluation)
|
|
37
|
+
defaults = {n: cls.__dict__[n] for n in names if n in cls.__dict__}
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
40
|
+
values = dict(zip(names, args, strict=False))
|
|
41
|
+
values.update(kwargs)
|
|
42
|
+
for name in names:
|
|
43
|
+
if name not in values:
|
|
44
|
+
if name not in defaults:
|
|
45
|
+
raise TypeError(f"{cls.__name__}() missing argument {name!r}")
|
|
46
|
+
d = defaults[name]
|
|
47
|
+
values[name] = d.make() if isinstance(d, _Factory) else d
|
|
48
|
+
object.__setattr__(self, name, values[name])
|
|
49
|
+
|
|
50
|
+
def as_dict(self) -> dict:
|
|
51
|
+
return {n: getattr(self, n) for n in names}
|
|
52
|
+
|
|
53
|
+
cls.__init__ = __init__
|
|
54
|
+
cls.__eq__ = lambda self, other: type(other) is type(self) and as_dict(self) == as_dict(other)
|
|
55
|
+
cls.__repr__ = lambda self: f"{cls.__name__}({as_dict(self)!r})"
|
|
56
|
+
if "as_dict" not in cls.__dict__:
|
|
57
|
+
cls.as_dict = as_dict
|
|
58
|
+
cls._fields = tuple(names)
|
|
59
|
+
return cls
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def asdict(obj: object) -> dict:
|
|
63
|
+
return {n: getattr(obj, n) for n in obj._fields} # type: ignore[attr-defined]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@record
|
|
67
|
+
class Capabilities:
|
|
68
|
+
readonly: bool # can enforce a read-only run
|
|
69
|
+
levels: tuple[str, ...] # write isolation levels the adapter can enforce
|
|
70
|
+
command_network_off: bool # can deny network to the worker's shell commands
|
|
71
|
+
cost: str # "usd" observed, "quota" (subscription share), "none"
|
|
72
|
+
resume: bool
|
|
73
|
+
structured_events: bool
|
|
74
|
+
paid: bool # books money against the monthly cap
|
|
75
|
+
notes: str = ""
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@record
|
|
82
|
+
class Probe:
|
|
83
|
+
installed: bool
|
|
84
|
+
version: str | None = None
|
|
85
|
+
path: str | None = None
|
|
86
|
+
flags: dict[str, bool] = field(default_factory=dict)
|
|
87
|
+
auth: str = "unknown" # ok | missing | unknown: from local evidence only, never a paid call
|
|
88
|
+
problems: list[str] = field(default_factory=list)
|
|
89
|
+
checked: float = field(default_factory=time.time)
|
|
90
|
+
compatible: bool = True
|
|
91
|
+
|
|
92
|
+
def as_dict(self) -> dict:
|
|
93
|
+
return asdict(self)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@record
|
|
97
|
+
class Capacity:
|
|
98
|
+
value: float | None # None = unknown, never a silent 50
|
|
99
|
+
unit: str # "pct_free" | "usd_left"
|
|
100
|
+
source: str
|
|
101
|
+
ts: float
|
|
102
|
+
confidence: str # high | medium | low | none
|
|
103
|
+
note: str = ""
|
|
104
|
+
resets_at: float | None = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def freshness_s(self) -> float:
|
|
108
|
+
return max(0.0, time.time() - self.ts)
|
|
109
|
+
|
|
110
|
+
def as_dict(self) -> dict:
|
|
111
|
+
return {**asdict(self), "freshness_s": round(self.freshness_s, 1)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@record
|
|
115
|
+
class RunContext:
|
|
116
|
+
job_id: str
|
|
117
|
+
spec: dict
|
|
118
|
+
prompt: str
|
|
119
|
+
resume: str | None
|
|
120
|
+
attempt: int
|
|
121
|
+
folder: Path
|
|
122
|
+
emit: Callable[..., object] # emit(phase, msg, source="adapter")
|
|
123
|
+
started: threading.Event # set once the engine may have spent anything
|
|
124
|
+
wrap: Callable[[list[str], dict[str, str]], tuple[list[str], dict[str, str]]] # isolation runner
|
|
125
|
+
env: dict[str, str] # minimal environment for the engine process
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@record
|
|
129
|
+
class Outcome:
|
|
130
|
+
ok: bool
|
|
131
|
+
subtype: str
|
|
132
|
+
result: str
|
|
133
|
+
session_id: str | None
|
|
134
|
+
cost_usd: float | None = None # None = unknown
|
|
135
|
+
turns: int | None = None
|
|
136
|
+
tokens: dict[str, int] = field(default_factory=dict)
|
|
137
|
+
commands: list[dict] = field(default_factory=list) # runner_observed: {"command", "exit_code"}
|
|
138
|
+
quota_exhausted: bool = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Adapter:
|
|
142
|
+
"""Base class. Subclasses set the class attributes and override build/parse."""
|
|
143
|
+
|
|
144
|
+
name = ""
|
|
145
|
+
family = ""
|
|
146
|
+
binary: str | None = None
|
|
147
|
+
model: str | None = None
|
|
148
|
+
supported = "" # human description of tested versions, e.g. ">=2.1"
|
|
149
|
+
capabilities: Capabilities
|
|
150
|
+
|
|
151
|
+
def probe(self) -> Probe:
|
|
152
|
+
if not self.binary:
|
|
153
|
+
return Probe(installed=True, version="sdk", path=None)
|
|
154
|
+
path = shutil.which(self.binary)
|
|
155
|
+
if not path:
|
|
156
|
+
return Probe(installed=False, problems=[f"{self.binary} not on PATH"])
|
|
157
|
+
version = _run([path, "--version"])
|
|
158
|
+
probe = Probe(installed=True, version=version.splitlines()[0][:80] if version else None, path=path)
|
|
159
|
+
help_text = _run([path, *self.help_args()]) if self.help_args() else ""
|
|
160
|
+
for flag in self.required_flags():
|
|
161
|
+
probe.flags[flag] = flag in help_text
|
|
162
|
+
if help_text and flag not in help_text:
|
|
163
|
+
probe.problems.append(f"flag {flag} not in `{self.binary} {' '.join(self.help_args())}`")
|
|
164
|
+
probe.compatible = not probe.problems
|
|
165
|
+
probe.auth = self.auth_state()
|
|
166
|
+
return probe
|
|
167
|
+
|
|
168
|
+
def available(self) -> bool:
|
|
169
|
+
"""Cheap installed check for routing (no subprocess): binary on PATH or module importable."""
|
|
170
|
+
if self.binary:
|
|
171
|
+
return shutil.which(self.binary) is not None
|
|
172
|
+
module = getattr(self, "module", None)
|
|
173
|
+
if module:
|
|
174
|
+
import importlib.util
|
|
175
|
+
|
|
176
|
+
return importlib.util.find_spec(module) is not None
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def help_args(self) -> list[str]:
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
def required_flags(self) -> list[str]:
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
def auth_state(self) -> str:
|
|
186
|
+
return "unknown"
|
|
187
|
+
|
|
188
|
+
def capacity(self) -> Capacity:
|
|
189
|
+
return Capacity(None, "pct_free", "none", time.time(), "none", "no quota data")
|
|
190
|
+
|
|
191
|
+
def run(self, ctx: RunContext) -> Outcome:
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CliAdapter(Adapter):
|
|
196
|
+
"""An engine driven as a headless CLI that prints JSON lines on stdout."""
|
|
197
|
+
|
|
198
|
+
def argv(self, ctx: RunContext) -> list[str]:
|
|
199
|
+
raise NotImplementedError
|
|
200
|
+
|
|
201
|
+
def parse(self, line: str, state: dict) -> str | None:
|
|
202
|
+
"""Update `state` from one stdout line; return a short activity text or None."""
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def run(self, ctx: RunContext) -> Outcome:
|
|
206
|
+
argv, env = ctx.wrap(self.argv(ctx), dict(ctx.env))
|
|
207
|
+
state: dict = {"text": [], "commands": [], "session": None, "tokens": {}, "cost": None}
|
|
208
|
+
raw = ctx.folder / f"output-{ctx.attempt}.jsonl"
|
|
209
|
+
with raw.open("a") as keep, (ctx.folder / "worker-stderr.log").open("a") as err:
|
|
210
|
+
proc = subprocess.Popen(
|
|
211
|
+
argv,
|
|
212
|
+
cwd=ctx.spec["cwd"],
|
|
213
|
+
stdin=subprocess.DEVNULL,
|
|
214
|
+
stdout=subprocess.PIPE,
|
|
215
|
+
stderr=err,
|
|
216
|
+
text=True,
|
|
217
|
+
env=env,
|
|
218
|
+
)
|
|
219
|
+
ctx.started.set()
|
|
220
|
+
assert proc.stdout is not None
|
|
221
|
+
last_emit = 0.0
|
|
222
|
+
for line in proc.stdout:
|
|
223
|
+
keep.write(line)
|
|
224
|
+
if not line.lstrip().startswith("{"):
|
|
225
|
+
state.setdefault("plain", []).append(line.rstrip("\n"))
|
|
226
|
+
try:
|
|
227
|
+
activity = self.parse(line, state)
|
|
228
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
229
|
+
activity = None
|
|
230
|
+
state.setdefault("format_errors", 0)
|
|
231
|
+
state["format_errors"] += 1
|
|
232
|
+
if activity and time.monotonic() - last_emit >= 1.0:
|
|
233
|
+
ctx.emit("tool", activity, source="adapter")
|
|
234
|
+
last_emit = time.monotonic()
|
|
235
|
+
code = proc.wait()
|
|
236
|
+
text = "\n".join(state["text"]).strip()
|
|
237
|
+
if not text and state.get("plain"):
|
|
238
|
+
text = "\n".join(state["plain"]).strip() # engine printed no structured events: keep its output
|
|
239
|
+
if state.get("error") and not text:
|
|
240
|
+
text = state["error"]
|
|
241
|
+
return Outcome(
|
|
242
|
+
ok=code == 0 and not state.get("error") and bool(text),
|
|
243
|
+
subtype=state.get("error_kind") or f"exit {code}",
|
|
244
|
+
result=text[-20000:],
|
|
245
|
+
session_id=state.get("session")
|
|
246
|
+
or ctx.resume
|
|
247
|
+
or ("continue" if self.capabilities.resume else None),
|
|
248
|
+
cost_usd=state.get("cost"),
|
|
249
|
+
tokens=state["tokens"],
|
|
250
|
+
commands=state["commands"],
|
|
251
|
+
quota_exhausted=bool(state.get("quota")),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run(argv: list[str], timeout: float = 10) -> str:
|
|
256
|
+
try:
|
|
257
|
+
done = subprocess.run(
|
|
258
|
+
argv, capture_output=True, text=True, timeout=timeout, check=False, stdin=subprocess.DEVNULL
|
|
259
|
+
)
|
|
260
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
261
|
+
return ""
|
|
262
|
+
return (done.stdout or done.stderr or "").strip()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def short_command(command: object) -> str:
|
|
266
|
+
"""A shell command as one short line, for progress (never full scripts)."""
|
|
267
|
+
if isinstance(command, list):
|
|
268
|
+
command = " ".join(str(c) for c in command)
|
|
269
|
+
text = str(command or "").strip()
|
|
270
|
+
for prefix in ("bash -lc ", "/bin/bash -lc ", "sh -c ", "/bin/sh -c "):
|
|
271
|
+
if text.startswith(prefix):
|
|
272
|
+
text = text[len(prefix) :].strip("'\"")
|
|
273
|
+
first = text.splitlines()[0] if text else ""
|
|
274
|
+
return first[:70] + ("…" if len(first) > 70 or "\n" in text else "")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def describe_command(command: str) -> str:
|
|
278
|
+
low = command.lower()
|
|
279
|
+
if any(k in low for k in ("pytest", "npm test", "go test", "cargo test", "unittest", "jest", "vitest")):
|
|
280
|
+
return f"running tests: {command}"
|
|
281
|
+
if low.startswith(("git commit", "git add")):
|
|
282
|
+
return f"committing: {command}"
|
|
283
|
+
if low.startswith(("rg ", "grep ", "find ", "ls ", "cat ", "sed -n", "head ", "tail ")):
|
|
284
|
+
return f"reading: {command}"
|
|
285
|
+
return f"running: {command}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def json_line(line: str) -> dict | None:
|
|
289
|
+
line = line.strip()
|
|
290
|
+
if not line.startswith("{"):
|
|
291
|
+
return None
|
|
292
|
+
data = json.loads(line)
|
|
293
|
+
return data if isinstance(data, dict) else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def env_names(names: Iterable[str]) -> dict[str, str]:
|
|
297
|
+
return {k: os.environ[k] for k in names if k in os.environ}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Claude models through the Claude Agent SDK, paid with the API key (monthly cap)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .. import config
|
|
12
|
+
from .base import Adapter, Capabilities, Capacity, Outcome, RunContext, describe_command, short_command
|
|
13
|
+
|
|
14
|
+
READONLY_TOOLS = ["Read", "Grep", "Glob", "WebSearch"]
|
|
15
|
+
SUMMARY_RULE = (
|
|
16
|
+
"\n\nWhen you finish, end your answer with a section that starts with the exact "
|
|
17
|
+
'line "## Summary" and has at most 10 short lines. Put the answer itself first '
|
|
18
|
+
"(the findings, the decision, the numbers), then files touched, commits, tests "
|
|
19
|
+
"run, what is left. The caller reads only this section. Use the task language."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def key_helper() -> Path:
|
|
24
|
+
"""Script that prints the key, so the key is never in the agent's environment."""
|
|
25
|
+
helper = config.HOME / "key-helper.sh"
|
|
26
|
+
helper.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
tmp = helper.with_suffix(f".{os.getpid()}.tmp")
|
|
28
|
+
tmp.write_text(
|
|
29
|
+
f"#!{sys.executable}\nfrom subtask.config import load_secrets\nprint(load_secrets().get('ANTHROPIC_API_KEY', ''))\n"
|
|
30
|
+
)
|
|
31
|
+
tmp.chmod(0o700)
|
|
32
|
+
os.replace(tmp, helper) # atomic: other workers may be running it right now
|
|
33
|
+
return helper
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def settings(sandbox: bool, git_dir: str | None, network_off: bool = False) -> str:
|
|
37
|
+
"""Per-run settings: OS sandbox for Bash, and best-effort read denies on secrets.
|
|
38
|
+
|
|
39
|
+
Not a hard boundary: an agent running as the same user can still find a way to
|
|
40
|
+
read files it may not Read. Use level="isolated" for a filesystem boundary.
|
|
41
|
+
"""
|
|
42
|
+
secret_paths = [
|
|
43
|
+
str(config.ENV_FILE),
|
|
44
|
+
str(config.HOME / "key-helper.sh"),
|
|
45
|
+
str(Path.home() / ".claude" / ".credentials.json"),
|
|
46
|
+
]
|
|
47
|
+
data: dict = {
|
|
48
|
+
"permissions": {
|
|
49
|
+
"deny": [f"{tool}(/{p})" for p in secret_paths for tool in ("Read", "Edit", "Write")]
|
|
50
|
+
+ [f"Bash(*{p}*)" for p in secret_paths]
|
|
51
|
+
+ [f"Bash(*{p.replace(str(Path.home()), '~')}*)" for p in secret_paths]
|
|
52
|
+
},
|
|
53
|
+
"apiKeyHelper": str(key_helper()),
|
|
54
|
+
}
|
|
55
|
+
if sandbox:
|
|
56
|
+
data["sandbox"] = {
|
|
57
|
+
"enabled": True,
|
|
58
|
+
"allowUnsandboxedCommands": False,
|
|
59
|
+
"failIfUnavailable": True,
|
|
60
|
+
"filesystem": {
|
|
61
|
+
# git data of a worktree lives in the main repo's .git: commits need it
|
|
62
|
+
"allowWrite": [str(Path.home() / ".cache")] + ([git_dir] if git_dir else []),
|
|
63
|
+
"denyRead": secret_paths,
|
|
64
|
+
"denyWrite": secret_paths,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
if network_off:
|
|
68
|
+
data["sandbox"]["network"] = {"allowedDomains": []}
|
|
69
|
+
return json.dumps(data)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def api_env() -> dict[str, str]:
|
|
73
|
+
env = {"IS_SANDBOX": "1", "ANTHROPIC_API_KEY": ""} # key comes from apiKeyHelper only
|
|
74
|
+
secrets = config.load_secrets()
|
|
75
|
+
if not secrets.get("ANTHROPIC_API_KEY"):
|
|
76
|
+
raise RuntimeError(f"ANTHROPIC_API_KEY missing (file {config.ENV_FILE})")
|
|
77
|
+
if secrets.get("ANTHROPIC_WORKSPACE_ID"):
|
|
78
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = f"anthropic-workspace-id: {secrets['ANTHROPIC_WORKSPACE_ID']}"
|
|
79
|
+
return env
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ClaudeApi(Adapter):
|
|
83
|
+
family = "api"
|
|
84
|
+
module = "claude_agent_sdk"
|
|
85
|
+
capabilities = Capabilities(
|
|
86
|
+
readonly=True,
|
|
87
|
+
levels=("worktree", "sandbox"),
|
|
88
|
+
command_network_off=True,
|
|
89
|
+
cost="usd",
|
|
90
|
+
resume=True,
|
|
91
|
+
structured_events=True,
|
|
92
|
+
paid=True,
|
|
93
|
+
notes="sandbox = Claude Code OS sandbox (bubblewrap on Linux), same user",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __init__(self, name: str, model: str) -> None:
|
|
97
|
+
self.name, self.model = name, model
|
|
98
|
+
|
|
99
|
+
def auth_state(self) -> str:
|
|
100
|
+
return "present" if config.load_secrets().get("ANTHROPIC_API_KEY") else "missing"
|
|
101
|
+
|
|
102
|
+
def probe(self):
|
|
103
|
+
probe = super().probe()
|
|
104
|
+
try:
|
|
105
|
+
import importlib.metadata as md
|
|
106
|
+
|
|
107
|
+
probe.version = "claude-agent-sdk " + md.version("claude-agent-sdk")
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
probe.installed, probe.problems = False, ["claude-agent-sdk not installed"]
|
|
110
|
+
probe.auth = self.auth_state()
|
|
111
|
+
return probe
|
|
112
|
+
|
|
113
|
+
def capacity(self) -> Capacity:
|
|
114
|
+
from .. import ledger
|
|
115
|
+
|
|
116
|
+
cap = config.MONTHLY_CAP_USD
|
|
117
|
+
left = cap - ledger.committed()
|
|
118
|
+
return Capacity(
|
|
119
|
+
max(0.0, 100 * left / cap) if cap > 0 else 0.0,
|
|
120
|
+
"pct_free",
|
|
121
|
+
"local ledger vs monthly cap",
|
|
122
|
+
time.time(),
|
|
123
|
+
"high",
|
|
124
|
+
f"{left:.2f}$ of {cap:.0f}$ left this month",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def run(self, ctx: RunContext) -> Outcome:
|
|
128
|
+
import asyncio # lazy: 60+ ms at import, only the worker needs it
|
|
129
|
+
|
|
130
|
+
return asyncio.run(self._run(ctx))
|
|
131
|
+
|
|
132
|
+
async def _run(self, ctx: RunContext) -> Outcome:
|
|
133
|
+
from claude_agent_sdk import (
|
|
134
|
+
AssistantMessage,
|
|
135
|
+
ClaudeAgentOptions,
|
|
136
|
+
ResultMessage,
|
|
137
|
+
ToolResultBlock,
|
|
138
|
+
ToolUseBlock,
|
|
139
|
+
UserMessage,
|
|
140
|
+
query,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
spec = ctx.spec
|
|
144
|
+
readonly = spec.get("readonly", False)
|
|
145
|
+
policy = spec.get("policy") or {}
|
|
146
|
+
sandbox = not readonly and policy.get("level", "sandbox") != "worktree"
|
|
147
|
+
options = ClaudeAgentOptions(
|
|
148
|
+
cwd=spec["cwd"],
|
|
149
|
+
model=spec.get("model") or self.model,
|
|
150
|
+
max_turns=spec["max_turns"],
|
|
151
|
+
max_budget_usd=spec["max_usd"],
|
|
152
|
+
effort=spec["effort"],
|
|
153
|
+
permission_mode="dontAsk" if readonly else "bypassPermissions",
|
|
154
|
+
allowed_tools=READONLY_TOOLS if readonly else [],
|
|
155
|
+
disallowed_tools=["mcp__*", "WebFetch"] if readonly else ["mcp__*"],
|
|
156
|
+
settings=settings(sandbox, spec.get("git_dir"), policy.get("untrusted", False)),
|
|
157
|
+
system_prompt={"type": "preset", "preset": "claude_code", "append": SUMMARY_RULE},
|
|
158
|
+
env={**ctx.env, **api_env()},
|
|
159
|
+
resume=ctx.resume,
|
|
160
|
+
)
|
|
161
|
+
out = Outcome(ok=False, subtype="no result", result="", session_id=ctx.resume)
|
|
162
|
+
pending: dict[str, str] = {}
|
|
163
|
+
turns = 0
|
|
164
|
+
async for message in query(prompt=ctx.prompt, options=options):
|
|
165
|
+
ctx.started.set()
|
|
166
|
+
if isinstance(message, AssistantMessage):
|
|
167
|
+
turns += 1
|
|
168
|
+
for block in message.content:
|
|
169
|
+
if isinstance(block, ToolUseBlock):
|
|
170
|
+
text = _tool_text(block.name, block.input)
|
|
171
|
+
if block.name == "Bash":
|
|
172
|
+
pending[block.id] = short_command(block.input.get("command"))
|
|
173
|
+
ctx.emit("tool", text, source="adapter")
|
|
174
|
+
elif isinstance(message, UserMessage) and isinstance(message.content, list):
|
|
175
|
+
for block in message.content:
|
|
176
|
+
if isinstance(block, ToolResultBlock) and block.tool_use_id in pending:
|
|
177
|
+
out.commands.append(
|
|
178
|
+
{
|
|
179
|
+
"command": pending.pop(block.tool_use_id),
|
|
180
|
+
"exit_code": 1 if block.is_error else 0,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
elif isinstance(message, ResultMessage):
|
|
184
|
+
usage = message.usage or {}
|
|
185
|
+
out = Outcome(
|
|
186
|
+
ok=not message.is_error and message.subtype == "success",
|
|
187
|
+
subtype=message.subtype,
|
|
188
|
+
result=message.result or "",
|
|
189
|
+
session_id=message.session_id,
|
|
190
|
+
cost_usd=message.total_cost_usd or 0.0,
|
|
191
|
+
turns=message.num_turns,
|
|
192
|
+
tokens={k: v for k, v in usage.items() if isinstance(v, int)},
|
|
193
|
+
commands=out.commands,
|
|
194
|
+
)
|
|
195
|
+
out.turns = out.turns or turns
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _tool_text(name: str, args: dict) -> str:
|
|
200
|
+
if name == "Bash":
|
|
201
|
+
return describe_command(short_command(args.get("command")))
|
|
202
|
+
target = args.get("file_path") or args.get("path") or args.get("pattern") or args.get("query") or ""
|
|
203
|
+
verb = {"Read": "reading", "Edit": "editing", "Write": "writing", "Grep": "searching", "Glob": "listing"}
|
|
204
|
+
return f"{verb.get(name, name)} {Path(str(target)).name if '/' in str(target) else target}".strip()[:90]
|