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/progress.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Turn job events into short progress lines while a tool call is running.
|
|
2
|
+
|
|
3
|
+
`Reporter` is protocol-agnostic: it gets a `send(progress, message, total)`
|
|
4
|
+
callable from the server. It never invents a percentage: `progress` is a
|
|
5
|
+
counter of updates (MCP requires it to grow), the jobs-finished ratio is in
|
|
6
|
+
the message, and elapsed time is wall time of this call (monotonic clock).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from . import events, jobs
|
|
16
|
+
|
|
17
|
+
MIN_INTERVAL_S = float(os.environ.get("SUBTASK_PROGRESS_MIN_S", "2"))
|
|
18
|
+
HEARTBEAT_S = float(os.environ.get("SUBTASK_PROGRESS_HEARTBEAT_S", "10"))
|
|
19
|
+
NO_DETAIL = "worker running; internal activity not available"
|
|
20
|
+
LABELS = {
|
|
21
|
+
"admission": "admitting",
|
|
22
|
+
"worktree": "preparing worktree",
|
|
23
|
+
"spawn": "starting worker",
|
|
24
|
+
"start": "worker started",
|
|
25
|
+
"tool": "",
|
|
26
|
+
"verify": "verifying",
|
|
27
|
+
"reply": "resuming",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clock(seconds: float) -> str:
|
|
32
|
+
seconds = int(max(0, seconds))
|
|
33
|
+
return (
|
|
34
|
+
f"{seconds // 60:02d}:{seconds % 60:02d}"
|
|
35
|
+
if seconds < 3600
|
|
36
|
+
else f"{seconds // 3600}h{seconds % 3600 // 60:02d}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Reporter:
|
|
41
|
+
"""Coalesces events of the given jobs into throttled notifications."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
send: Callable[[float, str, float | None], None],
|
|
46
|
+
ids: list[str] | None = None,
|
|
47
|
+
label: str = "Subtask",
|
|
48
|
+
now: Callable[[], float] = time.monotonic,
|
|
49
|
+
min_interval: float = MIN_INTERVAL_S,
|
|
50
|
+
heartbeat: float = HEARTBEAT_S,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.send, self.label, self.now = send, label, now
|
|
53
|
+
self.min_interval, self.heartbeat = min_interval, heartbeat
|
|
54
|
+
self.t0 = now()
|
|
55
|
+
self.count = 0
|
|
56
|
+
self.last_sent = -1e9
|
|
57
|
+
self.last_text = ""
|
|
58
|
+
self.ids: list[str] = []
|
|
59
|
+
self.offsets: dict[str, int] = {}
|
|
60
|
+
self.activity: dict[str, str] = {}
|
|
61
|
+
self.states: dict[str, str] = {}
|
|
62
|
+
self.changed: dict[str, float] = {} # monotonic time of each job's last real change
|
|
63
|
+
self.detailed: dict[str, bool] = {} # True once the engine reported its own activity
|
|
64
|
+
self.sent_ok = 0
|
|
65
|
+
self.send_errors = 0
|
|
66
|
+
for job_id in ids or []:
|
|
67
|
+
self.add(job_id)
|
|
68
|
+
|
|
69
|
+
def add(self, job_id: str) -> None:
|
|
70
|
+
if job_id not in self.offsets:
|
|
71
|
+
self.ids.append(job_id)
|
|
72
|
+
# Start from the current end: only what happens during this call is news.
|
|
73
|
+
_, self.offsets[job_id] = events.read_since(job_id, 0, limit=10**9)
|
|
74
|
+
last = events.last(job_id)
|
|
75
|
+
self.activity[job_id] = _describe(last)
|
|
76
|
+
self.detailed[job_id] = last.get("source") == "adapter"
|
|
77
|
+
self.changed[job_id] = self.now()
|
|
78
|
+
|
|
79
|
+
def phase(self, text: str, force: bool = False) -> None:
|
|
80
|
+
"""A step of this call itself (admission, worktree...), not of a job."""
|
|
81
|
+
self._maybe_send(text, significant=True, force=force)
|
|
82
|
+
|
|
83
|
+
def poll(self) -> None:
|
|
84
|
+
significant = False
|
|
85
|
+
for job_id in self.ids:
|
|
86
|
+
new, self.offsets[job_id] = events.read_since(job_id, self.offsets[job_id])
|
|
87
|
+
for event in new:
|
|
88
|
+
text = _describe(event)
|
|
89
|
+
if event.get("source") == "adapter":
|
|
90
|
+
self.detailed[job_id] = True
|
|
91
|
+
if text and text != self.activity.get(job_id):
|
|
92
|
+
self.activity[job_id] = text
|
|
93
|
+
self.changed[job_id] = self.now()
|
|
94
|
+
significant = True
|
|
95
|
+
state = jobs.status(job_id).get("state", "unknown")
|
|
96
|
+
if state != self.states.get(job_id):
|
|
97
|
+
self.states[job_id] = state
|
|
98
|
+
self.changed[job_id] = self.now()
|
|
99
|
+
significant = True
|
|
100
|
+
self._maybe_send(self.summary(), significant)
|
|
101
|
+
|
|
102
|
+
def summary(self) -> str:
|
|
103
|
+
if not self.ids:
|
|
104
|
+
return ""
|
|
105
|
+
ended = [j for j in self.ids if self.states.get(j) in jobs.FINAL]
|
|
106
|
+
running = [j for j in self.ids if j not in ended]
|
|
107
|
+
if len(self.ids) == 1:
|
|
108
|
+
job = self.ids[0]
|
|
109
|
+
state = self.states.get(job, "?")
|
|
110
|
+
if state in jobs.FINAL:
|
|
111
|
+
return f"{job} {state} — {self.activity.get(job) or state}"
|
|
112
|
+
return f"{job} — {self._what(job)}"
|
|
113
|
+
head = f"{len(ended)}/{len(self.ids)} ended"
|
|
114
|
+
if running:
|
|
115
|
+
job = max(running, key=lambda j: self.changed.get(j, 0.0)) # the latest real change
|
|
116
|
+
return f"{head} — {job} {self._what(job)}"
|
|
117
|
+
failed = sum(1 for j in ended if self.states.get(j) != "done")
|
|
118
|
+
return f"{head} — {len(ended) - failed} done, {failed} not done"
|
|
119
|
+
|
|
120
|
+
def _what(self, job: str) -> str:
|
|
121
|
+
"""The job's latest activity; says so plainly when the engine exposes none."""
|
|
122
|
+
quiet = self.now() - self.changed.get(job, self.t0) > 5
|
|
123
|
+
if self.states.get(job) == "running" and not self.detailed.get(job) and quiet:
|
|
124
|
+
return NO_DETAIL
|
|
125
|
+
return self.activity.get(job) or NO_DETAIL
|
|
126
|
+
|
|
127
|
+
def _maybe_send(self, text: str, significant: bool, force: bool = False) -> None:
|
|
128
|
+
if not text:
|
|
129
|
+
return
|
|
130
|
+
t = self.now()
|
|
131
|
+
due_change = significant and text != self.last_text and t - self.last_sent >= self.min_interval
|
|
132
|
+
due_beat = t - self.last_sent >= self.heartbeat
|
|
133
|
+
if not (force or due_change or due_beat):
|
|
134
|
+
return
|
|
135
|
+
self.count += 1
|
|
136
|
+
self.last_sent, self.last_text = t, text
|
|
137
|
+
message = f"{self.label} · {clock(t - self.t0)} · {text}"
|
|
138
|
+
try:
|
|
139
|
+
self.send(float(self.count), message, None)
|
|
140
|
+
self.sent_ok += 1
|
|
141
|
+
except Exception: # noqa: BLE001 - a broken client never stops the work
|
|
142
|
+
self.send_errors += 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _describe(event: dict) -> str:
|
|
146
|
+
if not event:
|
|
147
|
+
return ""
|
|
148
|
+
phase, msg = str(event.get("phase") or ""), str(event.get("msg") or "")
|
|
149
|
+
if phase == "tool":
|
|
150
|
+
return msg
|
|
151
|
+
label = LABELS.get(phase, phase) or ""
|
|
152
|
+
return f"{label}: {msg}" if msg and label else msg or label
|
subtask/redact.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Remove secrets from text before it is saved or shown.
|
|
2
|
+
|
|
3
|
+
Not a guarantee: a pattern list cannot recognise every secret. It is one layer
|
|
4
|
+
next to a minimal environment and the isolated runner, never a replacement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
MASK = "[REDACTED]"
|
|
13
|
+
|
|
14
|
+
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
15
|
+
(
|
|
16
|
+
"private-key",
|
|
17
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
18
|
+
),
|
|
19
|
+
("anthropic", re.compile(r"sk-ant-[A-Za-z0-9_-]{8,}")),
|
|
20
|
+
("openai", re.compile(r"\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}")),
|
|
21
|
+
("github", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})")),
|
|
22
|
+
("gitlab", re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}")),
|
|
23
|
+
("aws-key-id", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
|
|
24
|
+
("google", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
|
|
25
|
+
("slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}")),
|
|
26
|
+
("stripe", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}")),
|
|
27
|
+
("huggingface", re.compile(r"\bhf_[A-Za-z0-9]{30,}")),
|
|
28
|
+
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
|
29
|
+
("url-credentials", re.compile(r"(?<=://)[^/\s:@]+:[^/\s@]{3,}(?=@)")),
|
|
30
|
+
(
|
|
31
|
+
"assignment",
|
|
32
|
+
re.compile(
|
|
33
|
+
r"(?i)\b([A-Z0-9_]*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)"
|
|
34
|
+
r"(\s*[=:]\s*[\"']?)([^\s\"'`]{8,})"
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_known: set[str] = set()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def remember(values: Iterable[str]) -> None:
|
|
43
|
+
"""Exact secret values subtask itself loaded: always masked, whatever their shape."""
|
|
44
|
+
for value in values:
|
|
45
|
+
if value and len(value) >= 8:
|
|
46
|
+
_known.add(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def redact(text: str) -> str:
|
|
50
|
+
if not text:
|
|
51
|
+
return text
|
|
52
|
+
for value in sorted(_known, key=len, reverse=True):
|
|
53
|
+
text = text.replace(value, MASK)
|
|
54
|
+
for name, pattern in PATTERNS:
|
|
55
|
+
if name == "assignment":
|
|
56
|
+
text = pattern.sub(lambda m: m.group(1) + m.group(2) + MASK, text)
|
|
57
|
+
elif name == "anthropic":
|
|
58
|
+
text = pattern.sub("sk-ant-" + MASK, text)
|
|
59
|
+
else:
|
|
60
|
+
text = pattern.sub(MASK, text)
|
|
61
|
+
return text
|
subtask/roi.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Delegation ROI from local telemetry: separate ratios with units, never one magic number.
|
|
2
|
+
|
|
3
|
+
What the server can see: worker time, charged/observed cost, outcomes,
|
|
4
|
+
verification, parent round trips, and the size of what the parent read
|
|
5
|
+
(summary) versus what it would have read (full result). It cannot see the
|
|
6
|
+
parent's own context or what the parent would have spent doing the task
|
|
7
|
+
itself: that baseline needs the benchmark harness (bench/e2e.py).
|
|
8
|
+
Token figures here are estimates (bytes / ratio) and are labelled so.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import statistics
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
from . import telemetry
|
|
17
|
+
|
|
18
|
+
RATIOS = (3.0, 4.0, 5.0) # bytes per token, for the sensitivity line
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _median(values: list[float]) -> float | None:
|
|
22
|
+
return round(statistics.median(values), 1) if values else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute(rows: list[dict] | None = None) -> dict:
|
|
26
|
+
rows = telemetry.rows(months=12) if rows is None else rows
|
|
27
|
+
groups: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
|
28
|
+
for row in rows:
|
|
29
|
+
groups[(row.get("engine") or "?", row.get("kind") or "?")].append(row)
|
|
30
|
+
out = {}
|
|
31
|
+
for (engine, kind), items in sorted(groups.items()):
|
|
32
|
+
jobs = {r.get("job") for r in items}
|
|
33
|
+
starts = [r for r in items if r.get("attempt_kind") == "start"]
|
|
34
|
+
verified = [r for r in items if r.get("verified") is True]
|
|
35
|
+
verified_jobs = {r.get("job") for r in verified}
|
|
36
|
+
done_unverified = {
|
|
37
|
+
r.get("job") for r in items if r.get("outcome") == "done" and r.get("verified") is None
|
|
38
|
+
}
|
|
39
|
+
first_ok = [r for r in starts if r.get("verified") is True]
|
|
40
|
+
usd = [float(r["cost_charged"]) for r in items if isinstance(r.get("cost_charged"), (int, float))]
|
|
41
|
+
paid = any(r.get("cost_reserved") for r in items)
|
|
42
|
+
saved = [max(0, (r.get("result_bytes") or 0) - (r.get("summary_bytes") or 0)) for r in items]
|
|
43
|
+
out[f"{engine}/{kind}"] = {
|
|
44
|
+
"jobs": len(jobs),
|
|
45
|
+
"attempts": len(items),
|
|
46
|
+
"replies": sum(1 for r in items if r.get("attempt_kind") == "reply"),
|
|
47
|
+
"accepted_verified": len(verified_jobs),
|
|
48
|
+
"done_unverified": len(done_unverified - verified_jobs),
|
|
49
|
+
"first_attempt_verified_rate": round(len(first_ok) / len(starts), 3) if starts else None,
|
|
50
|
+
"usd_total_incl_failures": round(sum(usd), 4) if paid else None,
|
|
51
|
+
"usd_per_accepted": round(sum(usd) / len(verified_jobs), 4) if paid and verified_jobs else None,
|
|
52
|
+
"billing": "usd" if paid else "subscription/quota (not dollars)",
|
|
53
|
+
"median_run_s": _median([float(r["run_s"]) for r in items if r.get("run_s") is not None]),
|
|
54
|
+
"median_queue_s": _median([float(r["queue_s"]) for r in items if r.get("queue_s") is not None]),
|
|
55
|
+
"context_saved_tokens_est": {f"bytes/{k:g}": int(sum(saved) / k) for k in RATIOS},
|
|
56
|
+
"summary_tokens_est_bytes/4": int(sum(r.get("summary_bytes") or 0 for r in items) / 4),
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def report(rows: list[dict] | None = None) -> str:
|
|
62
|
+
data = compute(rows)
|
|
63
|
+
if not data:
|
|
64
|
+
return "no telemetry yet"
|
|
65
|
+
lines = [
|
|
66
|
+
(
|
|
67
|
+
"engine/kind: jobs · verified · done-unverified · 1st-try verified · $ (incl. failures) · $/verified · "
|
|
68
|
+
"median run · context saved (tokens, est. bytes/4 [3..5])"
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
for key, d in data.items():
|
|
72
|
+
saved = d["context_saved_tokens_est"]
|
|
73
|
+
usd = "-" if d["usd_total_incl_failures"] is None else f"{d['usd_total_incl_failures']}$"
|
|
74
|
+
per = "-" if d["usd_per_accepted"] is None else f"{d['usd_per_accepted']}$"
|
|
75
|
+
rate = "-" if d["first_attempt_verified_rate"] is None else f"{d['first_attempt_verified_rate']:.0%}"
|
|
76
|
+
lines.append(
|
|
77
|
+
f"{key}: {d['jobs']} · {d['accepted_verified']} · {d['done_unverified']} · {rate} · {usd} · {per} · "
|
|
78
|
+
f"{d['median_run_s']}s · {saved['bytes/4']} [{saved['bytes/5']}..{saved['bytes/3']}]"
|
|
79
|
+
)
|
|
80
|
+
lines.append(
|
|
81
|
+
"Notes: unverified 'done' is not counted as accepted; subscription engines have no dollar cost here; "
|
|
82
|
+
"the parent-side baseline (doing it yourself) is measured only by bench/e2e.py."
|
|
83
|
+
)
|
|
84
|
+
return "\n".join(lines)
|
subtask/router.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Pick an engine: filter what is admissible, then rank by an explicit utility.
|
|
2
|
+
|
|
3
|
+
u = w_success * P(success | engine, kind) probability 0..1
|
|
4
|
+
- w_cost * expected_cost_usd / budget_usd 0..1 (paid engines only)
|
|
5
|
+
- w_latency * expected_minutes / 60 hours
|
|
6
|
+
- w_scarcity * (1 - free/100) 0..1; unknown free -> prior, flagged
|
|
7
|
+
|
|
8
|
+
P(success) is a prior from the kind's ranking unless the local history has at
|
|
9
|
+
least `min_samples` verified outcomes for that engine and kind. The weights
|
|
10
|
+
are configuration, not a proven optimum: `routing.json` in the config dir.
|
|
11
|
+
`legacy` mode keeps the original heuristic (free% - 5*rank) as a baseline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import capacity, config, ledger, policy, telemetry
|
|
21
|
+
from .engines import registry
|
|
22
|
+
from .engines.base import field, record
|
|
23
|
+
|
|
24
|
+
# Which engines fit which kind of task, best first. Plain `opencode` is manual-only.
|
|
25
|
+
KINDS = {
|
|
26
|
+
"read": ["haiku-api", "dsh", "kimi", "opencode-go", "sonnet-api"],
|
|
27
|
+
"code": ["sonnet-api", "opencode-go", "dsh", "codex", "kimi"],
|
|
28
|
+
"review": ["sonnet-api", "codex", "kimi"],
|
|
29
|
+
"hard": ["opus-api", "codex", "sonnet-api"],
|
|
30
|
+
}
|
|
31
|
+
DEFAULTS = {
|
|
32
|
+
"mode": "utility",
|
|
33
|
+
"w_success": 100.0,
|
|
34
|
+
"w_cost": 20.0,
|
|
35
|
+
"w_latency": 10.0,
|
|
36
|
+
"w_scarcity": 100.0,
|
|
37
|
+
"unknown_free_prior": 50.0,
|
|
38
|
+
"min_free": 10.0,
|
|
39
|
+
"min_samples": 10,
|
|
40
|
+
"prior_success_top": 0.8,
|
|
41
|
+
"prior_success_step": 0.05,
|
|
42
|
+
"prior_minutes": 10.0,
|
|
43
|
+
"prior_cost_share": 0.5,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _median(values: list[float]) -> float:
|
|
48
|
+
ordered = sorted(values)
|
|
49
|
+
mid = len(ordered) // 2
|
|
50
|
+
return ordered[mid] if len(ordered) % 2 else (ordered[mid - 1] + ordered[mid]) / 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def settings() -> dict:
|
|
54
|
+
path = Path(os.environ.get("SUBTASK_ROUTING", config.ENV_FILE.parent / "routing.json"))
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(path.read_text())
|
|
57
|
+
except (OSError, json.JSONDecodeError):
|
|
58
|
+
data = {}
|
|
59
|
+
return {**DEFAULTS, **{k: v for k, v in data.items() if k in DEFAULTS}}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@record
|
|
63
|
+
class Option:
|
|
64
|
+
engine: str
|
|
65
|
+
admissible: bool
|
|
66
|
+
why: list[str] = field(default_factory=list)
|
|
67
|
+
level: str | None = None
|
|
68
|
+
terms: dict = field(default_factory=dict)
|
|
69
|
+
utility: float | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _history(kind: str) -> dict[str, list[dict]]:
|
|
73
|
+
by_engine: dict[str, list[dict]] = {}
|
|
74
|
+
for row in telemetry.rows():
|
|
75
|
+
if row.get("kind") == kind and row.get("attempt_kind") == "start":
|
|
76
|
+
by_engine.setdefault(row.get("engine", "?"), []).append(row)
|
|
77
|
+
return by_engine
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _installed(adapter) -> bool:
|
|
81
|
+
return adapter.available()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rank(kind: str, max_usd: float | None, wanted: dict) -> list[Option]:
|
|
85
|
+
cfg = settings()
|
|
86
|
+
candidates = KINDS.get(kind, KINDS["code"])
|
|
87
|
+
snap = capacity.snapshot()
|
|
88
|
+
budget = max_usd or config.DEFAULT_MAX_USD
|
|
89
|
+
left = config.MONTHLY_CAP_USD - ledger.committed()
|
|
90
|
+
history = _history(kind) if cfg["mode"] == "utility" else {}
|
|
91
|
+
options: list[Option] = []
|
|
92
|
+
for rank_i, name in enumerate(candidates):
|
|
93
|
+
adapter = registry.get(name)
|
|
94
|
+
option = Option(name, True)
|
|
95
|
+
options.append(option)
|
|
96
|
+
if adapter is None:
|
|
97
|
+
option.admissible, option.why = False, ["not registered"]
|
|
98
|
+
continue
|
|
99
|
+
level, reason = policy.effective(adapter, wanted)
|
|
100
|
+
option.level = level
|
|
101
|
+
if level is None:
|
|
102
|
+
option.admissible = False
|
|
103
|
+
option.why.append(reason)
|
|
104
|
+
if not _installed(adapter):
|
|
105
|
+
option.admissible = False
|
|
106
|
+
option.why.append(f"{adapter.binary or getattr(adapter, 'module', adapter.name)} not installed")
|
|
107
|
+
if adapter.capabilities.paid and left < budget:
|
|
108
|
+
option.admissible = False
|
|
109
|
+
option.why.append(f"monthly cap: {left:.2f}$ left < {budget:.2f}$ budget")
|
|
110
|
+
reading = snap.get(name) or {}
|
|
111
|
+
free = reading.get("value")
|
|
112
|
+
if free is not None and free < cfg["min_free"]:
|
|
113
|
+
option.admissible = False
|
|
114
|
+
option.why.append(f"free {free:.0f}% < {cfg['min_free']:.0f}% ({reading.get('note', '')})")
|
|
115
|
+
if not option.admissible:
|
|
116
|
+
continue
|
|
117
|
+
assumed = free is None
|
|
118
|
+
free_used = cfg["unknown_free_prior"] if assumed else free
|
|
119
|
+
if cfg["mode"] == "legacy":
|
|
120
|
+
option.utility = free_used - rank_i * 5
|
|
121
|
+
option.terms = {"free": free_used, "free_assumed": assumed, "rank": rank_i}
|
|
122
|
+
continue
|
|
123
|
+
rows = history.get(name, [])
|
|
124
|
+
verified = [r for r in rows if r.get("verified") is not None]
|
|
125
|
+
if len(verified) >= cfg["min_samples"]:
|
|
126
|
+
wins = sum(1 for r in verified if r["verified"])
|
|
127
|
+
p_success, p_src = (wins + 1) / (len(verified) + 2), f"learned n={len(verified)}"
|
|
128
|
+
else:
|
|
129
|
+
p_success = max(0.05, cfg["prior_success_top"] - cfg["prior_success_step"] * rank_i)
|
|
130
|
+
p_src = f"prior (rank {rank_i}, n={len(verified)} < {cfg['min_samples']})"
|
|
131
|
+
minutes = [r["run_s"] / 60 for r in rows if isinstance(r.get("run_s"), (int, float))]
|
|
132
|
+
exp_min = _median(minutes) if len(minutes) >= 3 else cfg["prior_minutes"]
|
|
133
|
+
costs = [r["cost_charged"] for r in rows if isinstance(r.get("cost_charged"), (int, float))]
|
|
134
|
+
if adapter.capabilities.paid:
|
|
135
|
+
exp_cost = _median(costs) if len(costs) >= 3 else cfg["prior_cost_share"] * budget
|
|
136
|
+
else:
|
|
137
|
+
exp_cost = 0.0 # subscription or unknown: its scarcity is the quota term, not dollars
|
|
138
|
+
scarcity = 1 - free_used / 100
|
|
139
|
+
option.terms = {
|
|
140
|
+
"p_success": round(p_success, 3), "p_source": p_src,
|
|
141
|
+
"exp_cost_usd": round(exp_cost, 3), "exp_minutes": round(exp_min, 1),
|
|
142
|
+
"free_pct": round(free_used, 1), "free_assumed": assumed,
|
|
143
|
+
"free_source": reading.get("source"), "free_confidence": reading.get("confidence"),
|
|
144
|
+
} # fmt: skip
|
|
145
|
+
option.utility = round(
|
|
146
|
+
cfg["w_success"] * p_success
|
|
147
|
+
- cfg["w_cost"] * min(1.0, exp_cost / budget)
|
|
148
|
+
- cfg["w_latency"] * exp_min / 60
|
|
149
|
+
- cfg["w_scarcity"] * scarcity,
|
|
150
|
+
2,
|
|
151
|
+
)
|
|
152
|
+
return sorted(options, key=lambda o: (o.admissible, o.utility or -1e9), reverse=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def pick(
|
|
156
|
+
kind: str = "code", max_usd: float | None = None, wanted: dict | None = None
|
|
157
|
+
) -> tuple[str, str, list[Option]]:
|
|
158
|
+
"""(engine, one-line reason, full ranking). engine '' when nothing is admissible."""
|
|
159
|
+
wanted = wanted or policy.demand()
|
|
160
|
+
options = rank(kind, max_usd, wanted)
|
|
161
|
+
best = next((o for o in options if o.admissible), None)
|
|
162
|
+
if best is None:
|
|
163
|
+
why = "; ".join(f"{o.engine}: {', '.join(o.why)}" for o in options)
|
|
164
|
+
return "", f"no engine can take a {kind} task ({why})"[:400], options
|
|
165
|
+
t = best.terms
|
|
166
|
+
if "p_success" in t:
|
|
167
|
+
reason = (
|
|
168
|
+
f"{kind} → {best.engine} (u={best.utility}: P={t['p_success']} {t['p_source']}, "
|
|
169
|
+
f"free {t['free_pct']:.0f}%{' assumed' if t['free_assumed'] else ''}, ~{t['exp_cost_usd']}$, "
|
|
170
|
+
f"~{t['exp_minutes']}m, level {best.level})"
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
reason = (
|
|
174
|
+
f"{kind} → {best.engine} (legacy: free {t['free']:.0f}%, rank {t['rank']}, level {best.level})"
|
|
175
|
+
)
|
|
176
|
+
return best.engine, reason, options
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def explain(kind: str, max_usd: float | None = None, wanted: dict | None = None) -> str:
|
|
180
|
+
_, reason, options = pick(kind, max_usd, wanted)
|
|
181
|
+
lines = [reason]
|
|
182
|
+
for o in options:
|
|
183
|
+
mark = "✓" if o.admissible else "✗"
|
|
184
|
+
lines.append(f"{mark} {o.engine:12} u={o.utility} {o.terms or ''} {'; '.join(o.why)}"[:220])
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def calibrate() -> str:
|
|
189
|
+
"""Success counts per engine and kind from verified outcomes; which cells are learned."""
|
|
190
|
+
cfg = settings()
|
|
191
|
+
table: dict[tuple[str, str], list[bool]] = {}
|
|
192
|
+
for row in telemetry.rows(months=12):
|
|
193
|
+
if row.get("verified") is not None and row.get("attempt_kind") == "start":
|
|
194
|
+
table.setdefault((row.get("engine", "?"), row.get("kind", "?")), []).append(bool(row["verified"]))
|
|
195
|
+
if not table:
|
|
196
|
+
return "no verified outcomes yet: routing uses priors (start jobs with verify= to collect them)"
|
|
197
|
+
lines = []
|
|
198
|
+
for (engine, kind), wins in sorted(table.items()):
|
|
199
|
+
n = len(wins)
|
|
200
|
+
state = "learned" if n >= cfg["min_samples"] else f"prior (needs {cfg['min_samples']})"
|
|
201
|
+
lines.append(f"{engine:12} {kind:7} n={n:3} success={sum(wins) / n:.0%} → {state}")
|
|
202
|
+
return "\n".join(lines)
|
subtask/runners.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""How the engine process is launched: directly, or inside a bubblewrap sandbox.
|
|
2
|
+
|
|
3
|
+
The isolated runner is a real boundary for the filesystem: the engine sees a
|
|
4
|
+
read-only system, its worktree and git dir, and a private HOME that only holds
|
|
5
|
+
the engine's own state directories. It does not hide the engine's own
|
|
6
|
+
credentials from the engine (it needs them), and model traffic keeps the network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SYSTEM_RO = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/opt", "/nix", "/snap"]
|
|
16
|
+
BASE_ENV = ("PATH", "LANG", "LC_ALL", "TERM", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS")
|
|
17
|
+
|
|
18
|
+
# Per engine family: state directories under HOME the engine needs (read-write).
|
|
19
|
+
STATE_DIRS = {
|
|
20
|
+
"codex": [".codex"],
|
|
21
|
+
"kimi": [".kimi-code", ".kimi", ".config/kimi", ".local/share/kimi", ".cache/kimi"],
|
|
22
|
+
"opencode": [".local/share/opencode", ".config/opencode", ".cache/opencode", ".local/state/opencode"],
|
|
23
|
+
"api": [".claude", ".claude.json"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def minimal_env(extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
28
|
+
env = {k: os.environ[k] for k in BASE_ENV if k in os.environ}
|
|
29
|
+
env["HOME"] = str(Path.home())
|
|
30
|
+
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
31
|
+
env.update(extra or {})
|
|
32
|
+
return env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bwrap_available() -> bool:
|
|
36
|
+
"""bwrap installed AND able to create its namespaces here (some hosts forbid user namespaces)."""
|
|
37
|
+
exe = shutil.which("bwrap")
|
|
38
|
+
if not exe:
|
|
39
|
+
return False
|
|
40
|
+
import subprocess
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
done = subprocess.run(
|
|
44
|
+
[exe, "--ro-bind", "/", "/", "--unshare-pid", "--", "true"],
|
|
45
|
+
capture_output=True, timeout=10, check=False,
|
|
46
|
+
) # fmt: skip
|
|
47
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
48
|
+
return False
|
|
49
|
+
return done.returncode == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def direct(argv: list[str], env: dict[str, str]) -> tuple[list[str], dict[str, str]]:
|
|
53
|
+
return argv, env
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def isolated(spec: dict, family: str):
|
|
57
|
+
"""A wrap() for RunContext that runs argv under bwrap, or raises if bwrap is missing."""
|
|
58
|
+
bwrap = shutil.which("bwrap")
|
|
59
|
+
if not bwrap or not bwrap_available():
|
|
60
|
+
raise RuntimeError("policy isolated needs a working bubblewrap (bwrap); refusing to run unisolated")
|
|
61
|
+
home = Path.home()
|
|
62
|
+
|
|
63
|
+
def wrap(argv: list[str], env: dict[str, str]) -> tuple[list[str], dict[str, str]]:
|
|
64
|
+
args = [
|
|
65
|
+
bwrap,
|
|
66
|
+
"--die-with-parent",
|
|
67
|
+
"--new-session",
|
|
68
|
+
"--unshare-pid",
|
|
69
|
+
"--unshare-ipc",
|
|
70
|
+
"--unshare-uts",
|
|
71
|
+
]
|
|
72
|
+
for path in SYSTEM_RO:
|
|
73
|
+
if os.path.exists(path):
|
|
74
|
+
args += ["--ro-bind", path, path]
|
|
75
|
+
args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", str(home)]
|
|
76
|
+
exe = shutil.which(argv[0], path=env.get("PATH"))
|
|
77
|
+
if exe:
|
|
78
|
+
for candidate in {Path(exe).parent, Path(exe).resolve().parent}:
|
|
79
|
+
if not any(str(candidate).startswith(p + "/") or str(candidate) == p for p in SYSTEM_RO):
|
|
80
|
+
# the engine binary lives outside the system dirs (e.g. ~/.local/bin): read-only
|
|
81
|
+
args += ["--ro-bind-try", str(candidate), str(candidate)]
|
|
82
|
+
real = Path(exe).resolve()
|
|
83
|
+
if home in real.parents:
|
|
84
|
+
parts = real.relative_to(home).parts
|
|
85
|
+
depth = 3 if parts[0] == ".local" else 1 # ~/.local/share/<tool>, or ~/.<tool>
|
|
86
|
+
if len(parts) > depth:
|
|
87
|
+
root = home.joinpath(*parts[:depth])
|
|
88
|
+
args += ["--ro-bind-try", str(root), str(root)]
|
|
89
|
+
for rel in STATE_DIRS.get(family, []):
|
|
90
|
+
target = home / rel
|
|
91
|
+
if target.exists():
|
|
92
|
+
args += ["--bind", str(target), str(target)]
|
|
93
|
+
for key in ("cwd", "git_dir"):
|
|
94
|
+
if spec.get(key):
|
|
95
|
+
args += ["--bind", spec[key], spec[key]]
|
|
96
|
+
args += ["--chdir", spec["cwd"], "--setenv", "HOME", str(home), "--"]
|
|
97
|
+
return args + argv, env
|
|
98
|
+
|
|
99
|
+
return wrap
|