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/engines/codex.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Codex CLI (`codex exec --json`). Tested with codex-cli 0.162."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .base import Capabilities, Capacity, CliAdapter, RunContext, describe_command, json_line, short_command
|
|
12
|
+
|
|
13
|
+
LIMIT_RE = re.compile(
|
|
14
|
+
r"try again at ([A-Z][a-z]{2}) (\d{1,2})(?:st|nd|rd|th)?, (\d{4}) (\d{1,2}:\d{2} [AP]M)"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Codex(CliAdapter):
|
|
19
|
+
name = "codex"
|
|
20
|
+
family = "codex"
|
|
21
|
+
binary = "codex"
|
|
22
|
+
supported = "codex-cli >=0.160"
|
|
23
|
+
capabilities = Capabilities(
|
|
24
|
+
readonly=True,
|
|
25
|
+
levels=("worktree", "sandbox", "isolated"),
|
|
26
|
+
command_network_off=True,
|
|
27
|
+
cost="quota",
|
|
28
|
+
resume=True,
|
|
29
|
+
structured_events=True,
|
|
30
|
+
paid=False,
|
|
31
|
+
notes="sandbox = codex workspace-write (same user); worktree = no codex sandbox, opt-in only",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def help_args(self) -> list[str]:
|
|
35
|
+
return ["exec", "--help"]
|
|
36
|
+
|
|
37
|
+
def required_flags(self) -> list[str]:
|
|
38
|
+
return ["--json", "--sandbox", "--output-last-message", "--add-dir", "--skip-git-repo-check"]
|
|
39
|
+
|
|
40
|
+
def auth_state(self) -> str:
|
|
41
|
+
return "present" if (Path.home() / ".codex" / "auth.json").is_file() else "missing"
|
|
42
|
+
|
|
43
|
+
def argv(self, ctx: RunContext) -> list[str]:
|
|
44
|
+
spec = ctx.spec
|
|
45
|
+
level = spec.get("policy", {}).get("level", "sandbox")
|
|
46
|
+
if spec.get("readonly"):
|
|
47
|
+
mode = ["-s", "read-only"]
|
|
48
|
+
elif level == "worktree":
|
|
49
|
+
mode = ["--dangerously-bypass-approvals-and-sandbox"]
|
|
50
|
+
else: # sandbox, isolated: codex's own workspace-write sandbox, network off for commands
|
|
51
|
+
mode = ["-s", "workspace-write", "-c", 'approval_policy="never"']
|
|
52
|
+
if spec.get("git_dir"):
|
|
53
|
+
mode += ["--add-dir", spec["git_dir"]] # commits need the main repo's .git
|
|
54
|
+
argv = ["codex", "exec", "--json", "--skip-git-repo-check", "-C", spec["cwd"], *mode]
|
|
55
|
+
argv += ["-o", str(ctx.folder / f"last-message-{ctx.attempt}.txt")]
|
|
56
|
+
if spec.get("model"):
|
|
57
|
+
argv += ["-m", spec["model"]]
|
|
58
|
+
argv += ["resume", ctx.resume, "--", ctx.prompt] if ctx.resume else ["--", ctx.prompt]
|
|
59
|
+
return argv
|
|
60
|
+
|
|
61
|
+
def parse(self, line: str, state: dict) -> str | None:
|
|
62
|
+
event = json_line(line)
|
|
63
|
+
if not event:
|
|
64
|
+
return None
|
|
65
|
+
kind = event.get("type")
|
|
66
|
+
if kind == "thread.started":
|
|
67
|
+
state["session"] = event.get("thread_id")
|
|
68
|
+
elif kind in ("item.started", "item.completed"):
|
|
69
|
+
item = event.get("item") or {}
|
|
70
|
+
if item.get("type") == "command_execution":
|
|
71
|
+
cmd = short_command(item.get("command"))
|
|
72
|
+
if kind == "item.completed":
|
|
73
|
+
state["commands"].append({"command": cmd, "exit_code": item.get("exit_code")})
|
|
74
|
+
return None
|
|
75
|
+
return describe_command(cmd)
|
|
76
|
+
if item.get("type") == "agent_message" and kind == "item.completed":
|
|
77
|
+
state["text"].append(item.get("text") or "")
|
|
78
|
+
if item.get("type") == "file_change" and kind == "item.completed":
|
|
79
|
+
files = [c.get("path", "") for c in item.get("changes") or []]
|
|
80
|
+
return "editing " + ", ".join(Path(f).name for f in files[:3])
|
|
81
|
+
elif kind == "turn.completed":
|
|
82
|
+
usage = event.get("usage") or {}
|
|
83
|
+
for key, value in usage.items():
|
|
84
|
+
if isinstance(value, int):
|
|
85
|
+
state["tokens"][key] = state["tokens"].get(key, 0) + value
|
|
86
|
+
elif kind in ("error", "turn.failed"):
|
|
87
|
+
message = event.get("message") or (event.get("error") or {}).get("message") or "error"
|
|
88
|
+
state["error"] = message
|
|
89
|
+
if "usage limit" in message.lower():
|
|
90
|
+
state["quota"] = True
|
|
91
|
+
state["error_kind"] = "quota exhausted"
|
|
92
|
+
state["resets_at"] = parse_reset(message)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def run(self, ctx: RunContext):
|
|
96
|
+
outcome = super().run(ctx)
|
|
97
|
+
last = ctx.folder / f"last-message-{ctx.attempt}.txt"
|
|
98
|
+
if last.is_file() and last.read_text().strip():
|
|
99
|
+
outcome.result = last.read_text()[-20000:]
|
|
100
|
+
outcome.ok = outcome.ok or (outcome.subtype == "exit 0")
|
|
101
|
+
return outcome
|
|
102
|
+
|
|
103
|
+
def capacity(self) -> Capacity:
|
|
104
|
+
return codex_capacity()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_reset(message: str) -> float | None:
|
|
108
|
+
match = LIMIT_RE.search(message)
|
|
109
|
+
if not match:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
when = datetime.strptime(" ".join(match.groups()), "%b %d %Y %I:%M %p")
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
return when.timestamp()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tail(path: Path, size: int = 256 * 1024) -> str:
|
|
119
|
+
with path.open("rb") as fh:
|
|
120
|
+
fh.seek(0, 2)
|
|
121
|
+
fh.seek(max(0, fh.tell() - size))
|
|
122
|
+
return fh.read().decode(errors="replace")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find(obj: object, key: str) -> dict | None:
|
|
126
|
+
if isinstance(obj, dict):
|
|
127
|
+
if key in obj:
|
|
128
|
+
return obj[key]
|
|
129
|
+
for value in obj.values():
|
|
130
|
+
found = _find(value, key)
|
|
131
|
+
if found is not None:
|
|
132
|
+
return found
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def codex_capacity(root: Path | None = None) -> Capacity:
|
|
137
|
+
"""Free share from the newest `rate_limits` in local Codex session files.
|
|
138
|
+
|
|
139
|
+
Couples to Codex's local storage format: when it changes, this says
|
|
140
|
+
"unknown" with the reason instead of guessing.
|
|
141
|
+
"""
|
|
142
|
+
root = root or Path.home() / ".codex" / "sessions"
|
|
143
|
+
try:
|
|
144
|
+
files = sorted(root.glob("*/*/*/*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:5]
|
|
145
|
+
except OSError:
|
|
146
|
+
files = []
|
|
147
|
+
if not files:
|
|
148
|
+
return Capacity(None, "pct_free", "codex sessions", time.time(), "none", "no Codex session files")
|
|
149
|
+
for path in files:
|
|
150
|
+
for line in reversed(_tail(path).splitlines()):
|
|
151
|
+
if '"rate_limits"' not in line:
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
limits = _find(json.loads(line), "rate_limits")
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
continue
|
|
157
|
+
worst = None
|
|
158
|
+
for key in ("primary", "secondary"):
|
|
159
|
+
win = (limits or {}).get(key)
|
|
160
|
+
if not win:
|
|
161
|
+
continue
|
|
162
|
+
expired = float(win.get("resets_at") or 0) < time.time()
|
|
163
|
+
used = 0.0 if expired else float(win.get("used_percent", 0))
|
|
164
|
+
if worst is None or used > worst[0]:
|
|
165
|
+
worst = (used, win.get("resets_at"))
|
|
166
|
+
if worst:
|
|
167
|
+
observed = path.stat().st_mtime
|
|
168
|
+
age = time.time() - observed
|
|
169
|
+
confidence = "high" if age < 3600 else "medium" if age < 86400 else "low"
|
|
170
|
+
when = time.strftime("%m-%d %H:%M", time.localtime(worst[1])) if worst[1] else "?"
|
|
171
|
+
return Capacity(
|
|
172
|
+
100 - worst[0],
|
|
173
|
+
"pct_free",
|
|
174
|
+
f"codex session file ({path.name})",
|
|
175
|
+
observed,
|
|
176
|
+
confidence,
|
|
177
|
+
f"{worst[0]:.0f}% used (resets {when})",
|
|
178
|
+
resets_at=worst[1],
|
|
179
|
+
)
|
|
180
|
+
return Capacity(
|
|
181
|
+
None, "pct_free", "codex sessions", time.time(), "none", "no rate_limits block (format changed?)"
|
|
182
|
+
)
|
subtask/engines/demo.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Deterministic demo engine: no model, no network, no cost. Enabled with SUBTASK_DEMO=1.
|
|
2
|
+
|
|
3
|
+
It exists to show progress in a real client and to test the whole path.
|
|
4
|
+
The task text picks a script: "fail", "silent" (no activity details),
|
|
5
|
+
"long" (about 3 minutes), anything else succeeds in about 25 seconds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .base import Adapter, Capabilities, Capacity, Outcome, RunContext
|
|
13
|
+
|
|
14
|
+
STEPS = [
|
|
15
|
+
(4, "reading: rg -n 'def start' subtask/"),
|
|
16
|
+
(5, "editing core.py"),
|
|
17
|
+
(6, "running tests: pytest -q tests/test_core.py"),
|
|
18
|
+
(5, "committing: git commit -m 'demo'"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Demo(Adapter):
|
|
23
|
+
name = "demo"
|
|
24
|
+
family = "demo"
|
|
25
|
+
capabilities = Capabilities(
|
|
26
|
+
readonly=True, levels=("worktree", "sandbox", "isolated"), command_network_off=True, cost="none",
|
|
27
|
+
resume=True, structured_events=True, paid=False, notes="demo: runs nothing, spends nothing",
|
|
28
|
+
) # fmt: skip
|
|
29
|
+
|
|
30
|
+
def capacity(self) -> Capacity:
|
|
31
|
+
return Capacity(100.0, "pct_free", "demo", time.time(), "high", "demo engine")
|
|
32
|
+
|
|
33
|
+
def run(self, ctx: RunContext) -> Outcome:
|
|
34
|
+
task = ctx.prompt.lower()
|
|
35
|
+
steps = STEPS * 6 if "long" in task else STEPS
|
|
36
|
+
commands = []
|
|
37
|
+
for i, (seconds, text) in enumerate(steps):
|
|
38
|
+
if "silent" not in task:
|
|
39
|
+
ctx.emit("tool", text, source="adapter")
|
|
40
|
+
time.sleep(seconds)
|
|
41
|
+
if text.startswith("running tests"):
|
|
42
|
+
code = 1 if "fail" in task else 0
|
|
43
|
+
commands.append({"command": "pytest -q tests/test_core.py", "exit_code": code})
|
|
44
|
+
if code:
|
|
45
|
+
return Outcome(False, "tests failed", "## Summary\n- demo: tests failed (1 failing)", "demo",
|
|
46
|
+
commands=commands) # fmt: skip
|
|
47
|
+
if "fail" in task and i == 2:
|
|
48
|
+
break
|
|
49
|
+
return Outcome(True, "success", "## Summary\n- demo finished: 1 file changed, tests passed", "demo",
|
|
50
|
+
commands=commands) # fmt: skip
|
subtask/engines/dsh.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""DeepSeek Harness SDK (optional dependency `subtask-mcp[dsh]`), paid from the monthly cap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .. import config
|
|
9
|
+
from .base import Adapter, Capabilities, Capacity, Outcome, RunContext
|
|
10
|
+
from .claude_api import SUMMARY_RULE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dsh_cost(events: list[dict]) -> float:
|
|
14
|
+
"""USD from the usage of each model message; rates in config (DeepSeek list price)."""
|
|
15
|
+
total = 0.0
|
|
16
|
+
for event in events:
|
|
17
|
+
usage = event.get("data", {}).get("usage") if isinstance(event.get("data"), dict) else None
|
|
18
|
+
if not usage:
|
|
19
|
+
usage = event.get("usage")
|
|
20
|
+
if not isinstance(usage, dict):
|
|
21
|
+
continue
|
|
22
|
+
total += (
|
|
23
|
+
usage.get("inputTokens", 0) * config.DSH_INPUT_USD_M
|
|
24
|
+
+ usage.get("cacheReadTokens", 0) * config.DSH_CACHED_USD_M
|
|
25
|
+
+ usage.get("outputTokens", 0) * config.DSH_OUTPUT_USD_M
|
|
26
|
+
) / 1e6
|
|
27
|
+
return total
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Dsh(Adapter):
|
|
31
|
+
name = "dsh"
|
|
32
|
+
family = "dsh"
|
|
33
|
+
module = "deepseek_harness"
|
|
34
|
+
model = "deepseek-v4-flash"
|
|
35
|
+
capabilities = Capabilities(
|
|
36
|
+
readonly=False,
|
|
37
|
+
levels=("worktree",),
|
|
38
|
+
command_network_off=False,
|
|
39
|
+
cost="usd",
|
|
40
|
+
resume=True,
|
|
41
|
+
structured_events=True,
|
|
42
|
+
paid=True,
|
|
43
|
+
notes="harness-dependent sandbox, not enforced by subtask",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def probe(self):
|
|
47
|
+
probe = super().probe()
|
|
48
|
+
try:
|
|
49
|
+
import importlib.metadata as md
|
|
50
|
+
|
|
51
|
+
probe.version = "deepseek-harness-sdk " + md.version("deepseek-harness-sdk")
|
|
52
|
+
except Exception: # noqa: BLE001
|
|
53
|
+
probe.installed, probe.problems = False, ["deepseek-harness-sdk not installed (extra: dsh)"]
|
|
54
|
+
probe.auth = "present" if config.deepseek_key() else "missing"
|
|
55
|
+
return probe
|
|
56
|
+
|
|
57
|
+
def capacity(self) -> Capacity:
|
|
58
|
+
from .. import ledger
|
|
59
|
+
|
|
60
|
+
cap = config.MONTHLY_CAP_USD
|
|
61
|
+
left = cap - ledger.committed()
|
|
62
|
+
return Capacity(
|
|
63
|
+
max(0.0, 100 * left / cap) if cap > 0 else 0.0, "pct_free", "local ledger vs monthly cap",
|
|
64
|
+
time.time(), "high", f"{left:.2f}$ of {cap:.0f}$ left this month",
|
|
65
|
+
) # fmt: skip
|
|
66
|
+
|
|
67
|
+
def run(self, ctx: RunContext) -> Outcome:
|
|
68
|
+
from deepseek_harness import DeepSeekHarness
|
|
69
|
+
|
|
70
|
+
key = config.deepseek_key()
|
|
71
|
+
if not key:
|
|
72
|
+
raise RuntimeError(f"DeepSeek key missing (file {config.DSH_KEY_FILE})")
|
|
73
|
+
home = ctx.folder / "dsh-home"
|
|
74
|
+
home.mkdir(mode=0o700, exist_ok=True)
|
|
75
|
+
ctx.started.set()
|
|
76
|
+
with DeepSeekHarness(
|
|
77
|
+
cwd=ctx.spec["cwd"],
|
|
78
|
+
dsh_home=str(home),
|
|
79
|
+
model=self.model,
|
|
80
|
+
reasoning_effort=ctx.spec.get("effort") or "medium",
|
|
81
|
+
api_key=key, # reaches the dsh process only; its shell tool does not inherit it
|
|
82
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(home), "LANG": "C.UTF-8"},
|
|
83
|
+
) as harness:
|
|
84
|
+
prompt = ctx.prompt + SUMMARY_RULE
|
|
85
|
+
run = harness.run(prompt, session_id=ctx.resume) if ctx.resume else harness.run(prompt)
|
|
86
|
+
with (ctx.folder / f"output-{ctx.attempt}.jsonl").open("a") as log:
|
|
87
|
+
for event in run.events:
|
|
88
|
+
if event.get("type") in ("assistant/message", "turn/end"):
|
|
89
|
+
log.write(json.dumps(event)[:4000] + "\n")
|
|
90
|
+
return Outcome(
|
|
91
|
+
ok=run.finish_reason == "completed" and bool(run.final_response),
|
|
92
|
+
subtype=run.finish_reason or "error",
|
|
93
|
+
result=run.final_response or "",
|
|
94
|
+
session_id=run.session_id,
|
|
95
|
+
cost_usd=dsh_cost(run.events),
|
|
96
|
+
)
|
subtask/engines/kimi.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Kimi Code CLI (`kimi -p --output-format stream-json`). Tested with 2.1.1.
|
|
2
|
+
|
|
3
|
+
Kimi 2.1 refuses `--prompt` together with `--auto` ("Cannot combine --prompt
|
|
4
|
+
with --auto"); prompt mode already runs tools without asking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .base import Capabilities, CliAdapter, RunContext, describe_command, json_line, short_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Kimi(CliAdapter):
|
|
16
|
+
name = "kimi"
|
|
17
|
+
family = "kimi"
|
|
18
|
+
binary = "kimi"
|
|
19
|
+
supported = "kimi >=2.1"
|
|
20
|
+
capabilities = Capabilities(
|
|
21
|
+
readonly=False, # --plan is a mode, not an enforced read-only boundary
|
|
22
|
+
levels=("worktree", "isolated"),
|
|
23
|
+
command_network_off=False,
|
|
24
|
+
cost="none",
|
|
25
|
+
resume=True,
|
|
26
|
+
structured_events=True,
|
|
27
|
+
paid=False,
|
|
28
|
+
notes="no engine sandbox: use level=isolated for a filesystem boundary",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def help_args(self) -> list[str]:
|
|
32
|
+
return ["--help"]
|
|
33
|
+
|
|
34
|
+
def required_flags(self) -> list[str]:
|
|
35
|
+
return ["--output-format", "--prompt", "--session"]
|
|
36
|
+
|
|
37
|
+
def auth_state(self) -> str:
|
|
38
|
+
return "present" if (Path.home() / ".kimi").is_dir() else "unknown"
|
|
39
|
+
|
|
40
|
+
def argv(self, ctx: RunContext) -> list[str]:
|
|
41
|
+
argv = ["kimi", "--output-format", "stream-json"]
|
|
42
|
+
if ctx.spec.get("model"):
|
|
43
|
+
argv += ["-m", ctx.spec["model"]]
|
|
44
|
+
if ctx.resume:
|
|
45
|
+
argv += ["-S", ctx.resume] if ctx.resume != "continue" else ["-c"]
|
|
46
|
+
return argv + ["-p", ctx.prompt]
|
|
47
|
+
|
|
48
|
+
def parse(self, line: str, state: dict) -> str | None:
|
|
49
|
+
event = json_line(line)
|
|
50
|
+
if not event:
|
|
51
|
+
return None
|
|
52
|
+
role = event.get("role")
|
|
53
|
+
if role == "meta" and event.get("type") == "session.resume_hint":
|
|
54
|
+
state["session"] = event.get("session_id")
|
|
55
|
+
elif role == "assistant":
|
|
56
|
+
if event.get("content"):
|
|
57
|
+
content = event["content"]
|
|
58
|
+
state["text"].append(content if isinstance(content, str) else json.dumps(content))
|
|
59
|
+
for call in event.get("tool_calls") or []:
|
|
60
|
+
fn = call.get("function") or {}
|
|
61
|
+
name = fn.get("name", "tool")
|
|
62
|
+
try:
|
|
63
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
args = {}
|
|
66
|
+
state.setdefault("pending", {})[call.get("id")] = args.get("command")
|
|
67
|
+
if args.get("command"):
|
|
68
|
+
return describe_command(short_command(args["command"]))
|
|
69
|
+
target = args.get("path") or args.get("file_path") or args.get("pattern") or ""
|
|
70
|
+
return f"{name} {Path(str(target)).name}".strip()
|
|
71
|
+
elif role == "tool":
|
|
72
|
+
command = state.get("pending", {}).pop(event.get("tool_call_id"), None)
|
|
73
|
+
if command:
|
|
74
|
+
state["commands"].append({"command": short_command(command), "exit_code": None})
|
|
75
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""OpenCode CLI (`opencode run --format json`). Tested with v2.0.28."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .base import Capabilities, CliAdapter, RunContext, describe_command, json_line, short_command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenCode(CliAdapter):
|
|
11
|
+
name = "opencode"
|
|
12
|
+
family = "opencode"
|
|
13
|
+
binary = "opencode"
|
|
14
|
+
supported = "opencode >=2.0"
|
|
15
|
+
extra: tuple[str, ...] = ()
|
|
16
|
+
capabilities = Capabilities(
|
|
17
|
+
readonly=False,
|
|
18
|
+
levels=("worktree", "isolated"),
|
|
19
|
+
command_network_off=False,
|
|
20
|
+
cost="usd",
|
|
21
|
+
resume=True,
|
|
22
|
+
structured_events=True,
|
|
23
|
+
paid=False,
|
|
24
|
+
notes="cost is what OpenCode reports per step; billed by the provider, not the subtask cap",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def help_args(self) -> list[str]:
|
|
28
|
+
return ["run", "--help"]
|
|
29
|
+
|
|
30
|
+
def required_flags(self) -> list[str]:
|
|
31
|
+
return ["--format", "--auto", "--session", "--model"]
|
|
32
|
+
|
|
33
|
+
def auth_state(self) -> str:
|
|
34
|
+
auth = Path.home() / ".local" / "share" / "opencode" / "auth.json"
|
|
35
|
+
return "present" if auth.is_file() else "unknown"
|
|
36
|
+
|
|
37
|
+
def argv(self, ctx: RunContext) -> list[str]:
|
|
38
|
+
argv = ["opencode", "run", "--format", "json", "--auto", *self.extra]
|
|
39
|
+
model = ctx.spec.get("model") or self.model
|
|
40
|
+
if model:
|
|
41
|
+
argv += ["--model", model]
|
|
42
|
+
if ctx.resume:
|
|
43
|
+
argv += ["--session", ctx.resume] if ctx.resume != "continue" else ["--continue"]
|
|
44
|
+
return argv + ["--", ctx.prompt]
|
|
45
|
+
|
|
46
|
+
def parse(self, line: str, state: dict) -> str | None:
|
|
47
|
+
event = json_line(line)
|
|
48
|
+
if not event:
|
|
49
|
+
return None
|
|
50
|
+
state["session"] = event.get("sessionID") or state.get("session")
|
|
51
|
+
part = event.get("part") or {}
|
|
52
|
+
kind = event.get("type")
|
|
53
|
+
if kind == "tool_use":
|
|
54
|
+
tool = part.get("tool", "tool")
|
|
55
|
+
st = part.get("state") or {}
|
|
56
|
+
args = st.get("input") or {}
|
|
57
|
+
if args.get("command"):
|
|
58
|
+
cmd = short_command(args["command"])
|
|
59
|
+
if st.get("status") == "completed":
|
|
60
|
+
exit_code = (st.get("metadata") or {}).get("exit")
|
|
61
|
+
state["commands"].append({"command": cmd, "exit_code": exit_code})
|
|
62
|
+
return describe_command(cmd)
|
|
63
|
+
target = args.get("filePath") or args.get("path") or args.get("pattern") or ""
|
|
64
|
+
return f"{tool} {Path(str(target)).name}".strip()
|
|
65
|
+
if kind == "text":
|
|
66
|
+
state["text"].append(part.get("text") or "")
|
|
67
|
+
elif kind == "step_finish":
|
|
68
|
+
if isinstance(part.get("cost"), (int, float)):
|
|
69
|
+
state["cost_reported"] = state.get("cost_reported", 0.0) + part["cost"]
|
|
70
|
+
for key, value in (part.get("tokens") or {}).items():
|
|
71
|
+
if isinstance(value, int):
|
|
72
|
+
state["tokens"][key] = state["tokens"].get(key, 0) + value
|
|
73
|
+
elif kind == "error":
|
|
74
|
+
err = event.get("error") or {}
|
|
75
|
+
state["error"] = str(err.get("message") or err)[:500]
|
|
76
|
+
if "quota" in str(err.get("type", "")) or err.get("status") == 402:
|
|
77
|
+
state["quota"] = True
|
|
78
|
+
state["error_kind"] = "quota exhausted"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OpenCodeGo(OpenCode):
|
|
83
|
+
"""OpenCode on the opencode-go subscription: fixed model, private server per job."""
|
|
84
|
+
|
|
85
|
+
name = "opencode-go"
|
|
86
|
+
model = "opencode-go/deepseek-v4.1-flash#max"
|
|
87
|
+
extra = ("--standalone",)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Engine registry: built-ins plus plugins, without touching the core.
|
|
2
|
+
|
|
3
|
+
Plugins: a package declares `[project.entry-points."subtask.engines"] name = "module:factory"`,
|
|
4
|
+
or the environment lists `SUBTASK_ENGINE_PLUGINS=module:factory,...` (useful for tests).
|
|
5
|
+
A factory returns an Adapter instance. Plugins with a different ADAPTER_API are refused.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
from functools import cache
|
|
13
|
+
|
|
14
|
+
from .base import ADAPTER_API, Adapter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _builtins() -> dict[str, Adapter]:
|
|
18
|
+
from .claude_api import ClaudeApi
|
|
19
|
+
from .codex import Codex
|
|
20
|
+
from .dsh import Dsh
|
|
21
|
+
from .kimi import Kimi
|
|
22
|
+
from .opencode import OpenCode, OpenCodeGo
|
|
23
|
+
|
|
24
|
+
engines: list[Adapter] = [
|
|
25
|
+
ClaudeApi("sonnet-api", "sonnet"),
|
|
26
|
+
ClaudeApi("haiku-api", "haiku"),
|
|
27
|
+
ClaudeApi("opus-api", "opus"),
|
|
28
|
+
Codex(),
|
|
29
|
+
Kimi(),
|
|
30
|
+
OpenCodeGo(),
|
|
31
|
+
OpenCode(),
|
|
32
|
+
Dsh(),
|
|
33
|
+
]
|
|
34
|
+
if os.environ.get("SUBTASK_DEMO") == "1":
|
|
35
|
+
from .demo import Demo
|
|
36
|
+
|
|
37
|
+
engines.append(Demo())
|
|
38
|
+
return {e.name: e for e in engines}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(spec: str) -> Adapter:
|
|
42
|
+
module, _, attr = spec.partition(":")
|
|
43
|
+
factory = getattr(importlib.import_module(module), attr or "adapter")
|
|
44
|
+
adapter = factory() if callable(factory) and not isinstance(factory, Adapter) else factory
|
|
45
|
+
if not isinstance(adapter, Adapter):
|
|
46
|
+
raise TypeError(f"{spec} did not return an Adapter")
|
|
47
|
+
if getattr(adapter, "api", ADAPTER_API) != ADAPTER_API:
|
|
48
|
+
raise TypeError(f"{spec} targets adapter API {adapter.api}, this is {ADAPTER_API}")
|
|
49
|
+
return adapter
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
GROUP = "subtask.engines"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _entry_points() -> list[str]:
|
|
56
|
+
"""`subtask.engines` entry points, read straight from dist-info files.
|
|
57
|
+
|
|
58
|
+
importlib.metadata costs ~20 ms to import; this scan costs ~1 ms and reads
|
|
59
|
+
the same entry_points.txt files.
|
|
60
|
+
"""
|
|
61
|
+
import sys
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
found: list[str] = []
|
|
65
|
+
for root in sys.path:
|
|
66
|
+
try:
|
|
67
|
+
files = list(Path(root or ".").glob("*.dist-info/entry_points.txt"))
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
for path in files:
|
|
71
|
+
try:
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
if f"[{GROUP}]" not in text:
|
|
76
|
+
continue
|
|
77
|
+
section = text.split(f"[{GROUP}]", 1)[1].split("\n[", 1)[0]
|
|
78
|
+
for line in section.splitlines():
|
|
79
|
+
name, sep, value = line.partition("=")
|
|
80
|
+
if sep and name.strip() and not line.lstrip().startswith(("#", ";")):
|
|
81
|
+
found.append(value.strip())
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@cache
|
|
86
|
+
def _all() -> tuple[dict[str, Adapter], tuple[str, ...]]:
|
|
87
|
+
engines = _builtins()
|
|
88
|
+
errors: list[str] = []
|
|
89
|
+
specs = [s for s in os.environ.get("SUBTASK_ENGINE_PLUGINS", "").split(",") if s.strip()]
|
|
90
|
+
try:
|
|
91
|
+
specs += _entry_points()
|
|
92
|
+
except Exception as exc: # noqa: BLE001 - broken metadata must not break the built-ins
|
|
93
|
+
errors.append(f"entry points unreadable: {type(exc).__name__}: {exc}")
|
|
94
|
+
for spec in specs:
|
|
95
|
+
try:
|
|
96
|
+
adapter = _load(spec.strip())
|
|
97
|
+
except Exception as exc: # noqa: BLE001
|
|
98
|
+
errors.append(f"{spec}: {type(exc).__name__}: {exc}")
|
|
99
|
+
continue
|
|
100
|
+
if adapter.name in engines:
|
|
101
|
+
errors.append(f"{spec}: name {adapter.name!r} already taken")
|
|
102
|
+
continue
|
|
103
|
+
engines[adapter.name] = adapter
|
|
104
|
+
return engines, tuple(errors)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def all_engines() -> dict[str, Adapter]:
|
|
108
|
+
return _all()[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def plugin_errors() -> tuple[str, ...]:
|
|
112
|
+
return _all()[1]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get(name: str) -> Adapter | None:
|
|
116
|
+
return all_engines().get(name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def names() -> list[str]:
|
|
120
|
+
return list(all_engines())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def reset() -> None:
|
|
124
|
+
_all.cache_clear()
|