pilot-workers 0.2.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.
- pilot_workers/__init__.py +1 -0
- pilot_workers/__main__.py +3 -0
- pilot_workers/cli/__init__.py +0 -0
- pilot_workers/cli/dispatch.py +577 -0
- pilot_workers/cli/install.py +259 -0
- pilot_workers/cli/main.py +115 -0
- pilot_workers/cli/run.py +191 -0
- pilot_workers/credentials.py +102 -0
- pilot_workers/data/permissions/README.md +47 -0
- pilot_workers/data/permissions/relaxed.yaml +10 -0
- pilot_workers/data/permissions/strict.yaml +9 -0
- pilot_workers/data/providers/README.md +24 -0
- pilot_workers/data/providers/ds.yaml +11 -0
- pilot_workers/data/providers/glm.yaml +11 -0
- pilot_workers/data/providers/kimi-k3.yaml +11 -0
- pilot_workers/data/templates/code.md +28 -0
- pilot_workers/data/templates/explore.md +17 -0
- pilot_workers/data/templates/review.md +18 -0
- pilot_workers/data/templates/test.md +17 -0
- pilot_workers/fmt_events.py +218 -0
- pilot_workers/integrations/README.md +25 -0
- pilot_workers/integrations/claude-host/agents/ds-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/ds-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/ds-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/ds-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/glm-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/glm-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/glm-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/glm-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/kimi-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/kimi-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/kimi-tester.md +50 -0
- pilot_workers/integrations/claude-host/commands/glm/code.md +48 -0
- pilot_workers/integrations/claude-host/commands/glm/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/test.md +30 -0
- pilot_workers/integrations/claude-host/commands/kimi/code.md +47 -0
- pilot_workers/integrations/claude-host/commands/kimi/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/test.md +32 -0
- pilot_workers/integrations/codex-host/ds/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/ds/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/glm/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/glm/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/kimi/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/kimi/openai.yaml +4 -0
- pilot_workers/maintain.py +170 -0
- pilot_workers/policy.py +295 -0
- pilot_workers/prompts/code.md +6 -0
- pilot_workers/prompts/common.md +24 -0
- pilot_workers/prompts/explore.md +7 -0
- pilot_workers/prompts/review.md +5 -0
- pilot_workers/prompts/test.md +6 -0
- pilot_workers/providers.py +137 -0
- pilot_workers/runners/__init__.py +28 -0
- pilot_workers/runners/base.py +123 -0
- pilot_workers/runners/opencode_runner.py +377 -0
- pilot_workers/runtime.py +314 -0
- pilot_workers/scripts/install_runtime.sh +51 -0
- pilot_workers-0.2.0.dist-info/METADATA +84 -0
- pilot_workers-0.2.0.dist-info/RECORD +66 -0
- pilot_workers-0.2.0.dist-info/WHEEL +5 -0
- pilot_workers-0.2.0.dist-info/entry_points.txt +2 -0
- pilot_workers-0.2.0.dist-info/licenses/LICENSE +21 -0
- pilot_workers-0.2.0.dist-info/top_level.txt +1 -0
pilot_workers/policy.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mode policy: agents, shell permission rules, prompts, and runner config.
|
|
3
|
+
|
|
4
|
+
Permission-matching facts (read from the pinned OpenCode binary,
|
|
5
|
+
confirmed by live probes):
|
|
6
|
+
|
|
7
|
+
- Resolution is LAST-MATCH-WINS (`findLast` over insertion-ordered rules).
|
|
8
|
+
Deny rules must be inserted AFTER the allow rules they override.
|
|
9
|
+
- Each bash command is parsed with tree-sitter; every AST command node is
|
|
10
|
+
checked separately. Operator-symbol rules (`*|*`, `*&&*`, etc.) are dead
|
|
11
|
+
weight — each segment already hits the real allow/deny on its own.
|
|
12
|
+
- Redirected statements match with FULL text, so read-only modes keep a
|
|
13
|
+
`*>*` deny inserted LAST to block redirect writes.
|
|
14
|
+
|
|
15
|
+
This layer guards against mistakes, not malicious models. Not an OS sandbox.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import yaml # type: ignore[import-untyped]
|
|
25
|
+
except ImportError:
|
|
26
|
+
yaml = None # type: ignore[assignment]
|
|
27
|
+
|
|
28
|
+
from pilot_workers.providers import Provider
|
|
29
|
+
|
|
30
|
+
PERMISSIONS_DIR = Path(__file__).resolve().parent / "data" / "permissions"
|
|
31
|
+
|
|
32
|
+
VALID_MODES = ("code", "explore", "test", "review")
|
|
33
|
+
|
|
34
|
+
MODE_TO_AGENT = {
|
|
35
|
+
"code": "worker-code",
|
|
36
|
+
"explore": "worker-explore",
|
|
37
|
+
"test": "worker-test",
|
|
38
|
+
"review": "worker-review",
|
|
39
|
+
"resume": "worker-code",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
STEPS_BY_MODE = {"code": 120, "resume": 120, "review": 120, "explore": 80, "test": 80}
|
|
43
|
+
|
|
44
|
+
PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_prompt(mode: str) -> str:
|
|
48
|
+
prompt_mode = "code" if mode == "resume" else mode
|
|
49
|
+
parts = []
|
|
50
|
+
for name in ("common.md", f"{prompt_mode}.md"):
|
|
51
|
+
path = PROMPTS_DIR / name
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
raise RuntimeError(f"worker prompt file missing: {path}")
|
|
54
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
55
|
+
if not text:
|
|
56
|
+
raise RuntimeError(f"worker prompt file is empty: {path}")
|
|
57
|
+
parts.append(text)
|
|
58
|
+
return "\n\n".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def denied_shell_patterns() -> dict[str, str]:
|
|
62
|
+
return {
|
|
63
|
+
"git push*": "deny",
|
|
64
|
+
"git pull*": "deny",
|
|
65
|
+
"git fetch*": "deny",
|
|
66
|
+
"git clone*": "deny",
|
|
67
|
+
"git remote add*": "deny",
|
|
68
|
+
"gh *": "deny",
|
|
69
|
+
"curl *": "deny",
|
|
70
|
+
"wget *": "deny",
|
|
71
|
+
"ssh *": "deny",
|
|
72
|
+
"scp *": "deny",
|
|
73
|
+
"sftp *": "deny",
|
|
74
|
+
"rsync *": "deny",
|
|
75
|
+
"nc *": "deny",
|
|
76
|
+
"ncat *": "deny",
|
|
77
|
+
"npm publish*": "deny",
|
|
78
|
+
"pnpm publish*": "deny",
|
|
79
|
+
"yarn publish*": "deny",
|
|
80
|
+
"rm -rf /*": "deny",
|
|
81
|
+
"rm -rf ~*": "deny",
|
|
82
|
+
"sudo *": "deny",
|
|
83
|
+
"*auth.json*": "deny",
|
|
84
|
+
"*.env*": "deny",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def readonly_shell_permissions() -> dict[str, str]:
|
|
89
|
+
rules = {
|
|
90
|
+
"*": "deny",
|
|
91
|
+
"pwd": "allow",
|
|
92
|
+
"ls*": "allow",
|
|
93
|
+
"cat *": "allow",
|
|
94
|
+
"echo *": "allow",
|
|
95
|
+
"find *": "allow",
|
|
96
|
+
"rg *": "allow",
|
|
97
|
+
"grep *": "allow",
|
|
98
|
+
"sed *": "allow",
|
|
99
|
+
"awk *": "allow",
|
|
100
|
+
"head *": "allow",
|
|
101
|
+
"tail *": "allow",
|
|
102
|
+
"wc *": "allow",
|
|
103
|
+
"file *": "allow",
|
|
104
|
+
"stat *": "allow",
|
|
105
|
+
"npx tsc*": "allow",
|
|
106
|
+
"git status*": "allow",
|
|
107
|
+
"git diff*": "allow",
|
|
108
|
+
"git log*": "allow",
|
|
109
|
+
"git show*": "allow",
|
|
110
|
+
"git blame*": "allow",
|
|
111
|
+
"git branch*": "allow",
|
|
112
|
+
"git grep*": "allow",
|
|
113
|
+
"git rev-parse*": "allow",
|
|
114
|
+
"git ls-files*": "allow",
|
|
115
|
+
}
|
|
116
|
+
rules.update(denied_shell_patterns())
|
|
117
|
+
rules["*>*"] = "deny"
|
|
118
|
+
return rules
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_shell_permissions() -> dict[str, str]:
|
|
122
|
+
rules = readonly_shell_permissions()
|
|
123
|
+
rules.update(
|
|
124
|
+
{
|
|
125
|
+
"npm test*": "allow",
|
|
126
|
+
"npm run test*": "allow",
|
|
127
|
+
"npm run lint*": "allow",
|
|
128
|
+
"npm run typecheck*": "allow",
|
|
129
|
+
"npm run build*": "allow",
|
|
130
|
+
"pnpm test*": "allow",
|
|
131
|
+
"pnpm run test*": "allow",
|
|
132
|
+
"pnpm run lint*": "allow",
|
|
133
|
+
"pnpm run typecheck*": "allow",
|
|
134
|
+
"pnpm run build*": "allow",
|
|
135
|
+
"yarn test*": "allow",
|
|
136
|
+
"yarn lint*": "allow",
|
|
137
|
+
"yarn build*": "allow",
|
|
138
|
+
"bun test*": "allow",
|
|
139
|
+
"pytest*": "allow",
|
|
140
|
+
"python -m pytest*": "allow",
|
|
141
|
+
"go test*": "allow",
|
|
142
|
+
"cargo test*": "allow",
|
|
143
|
+
"npx vitest run*": "allow",
|
|
144
|
+
"dart test*": "allow",
|
|
145
|
+
"flutter test*": "allow",
|
|
146
|
+
"make test*": "allow",
|
|
147
|
+
"make check*": "allow",
|
|
148
|
+
"make lint*": "allow",
|
|
149
|
+
"just test*": "allow",
|
|
150
|
+
"composer test*": "allow",
|
|
151
|
+
"php artisan test*": "allow",
|
|
152
|
+
"mvn test*": "allow",
|
|
153
|
+
"./gradlew test*": "allow",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
rules.update(denied_shell_patterns())
|
|
157
|
+
return rules
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def code_shell_permissions() -> dict[str, str]:
|
|
161
|
+
rules = {"*": "allow"}
|
|
162
|
+
rules.update(denied_shell_patterns())
|
|
163
|
+
return rules
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def agent_permissions(mode: str) -> dict[str, Any]:
|
|
167
|
+
effective_mode = "code" if mode == "resume" else mode
|
|
168
|
+
editable = effective_mode == "code"
|
|
169
|
+
if effective_mode == "code":
|
|
170
|
+
bash = code_shell_permissions()
|
|
171
|
+
elif effective_mode == "test":
|
|
172
|
+
bash = test_shell_permissions()
|
|
173
|
+
else:
|
|
174
|
+
bash = readonly_shell_permissions()
|
|
175
|
+
return {
|
|
176
|
+
"*": "deny",
|
|
177
|
+
"read": "allow",
|
|
178
|
+
"edit": "allow" if editable else "deny",
|
|
179
|
+
"glob": "allow",
|
|
180
|
+
"grep": "allow",
|
|
181
|
+
"list": "allow",
|
|
182
|
+
"bash": bash,
|
|
183
|
+
"task": "deny",
|
|
184
|
+
"todowrite": "allow",
|
|
185
|
+
"webfetch": "deny",
|
|
186
|
+
"websearch": "deny",
|
|
187
|
+
"external_directory": "deny",
|
|
188
|
+
"lsp": "allow",
|
|
189
|
+
"skill": "deny",
|
|
190
|
+
"question": "deny",
|
|
191
|
+
"doom_loop": "deny",
|
|
192
|
+
"mcp_*": "deny",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_permission_profile(name: str) -> dict[str, Any]:
|
|
197
|
+
"""Load a permission profile YAML from the permissions/ directory."""
|
|
198
|
+
path = PERMISSIONS_DIR / f"{name}.yaml"
|
|
199
|
+
if not path.is_file():
|
|
200
|
+
raise RuntimeError(f"permission profile not found: {path}")
|
|
201
|
+
if yaml is None:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
f"pyyaml is required for custom permission profiles; "
|
|
204
|
+
f"install it with: pip install pyyaml"
|
|
205
|
+
)
|
|
206
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
207
|
+
if not isinstance(data, dict):
|
|
208
|
+
raise RuntimeError(f"permission profile must be a YAML mapping: {path}")
|
|
209
|
+
for key in data:
|
|
210
|
+
if key != "_all" and key not in VALID_MODES:
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
f"permission profile has unknown section '{key}' "
|
|
213
|
+
f"(expected _all or one of {VALID_MODES}): {path}"
|
|
214
|
+
)
|
|
215
|
+
return data
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _merge_permissions(
|
|
219
|
+
base: dict[str, Any], profile: dict[str, Any] | None, mode: str,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Merge a permission profile into base agent permissions.
|
|
222
|
+
|
|
223
|
+
Shell rules from the profile are appended after the base rules
|
|
224
|
+
(last-match-wins in OpenCode). Tool rules overwrite base values.
|
|
225
|
+
"""
|
|
226
|
+
if profile is None:
|
|
227
|
+
return base
|
|
228
|
+
|
|
229
|
+
sections = []
|
|
230
|
+
if "_all" in profile:
|
|
231
|
+
sections.append(profile["_all"])
|
|
232
|
+
effective_mode = "code" if mode == "resume" else mode
|
|
233
|
+
if effective_mode in profile:
|
|
234
|
+
sections.append(profile[effective_mode])
|
|
235
|
+
if not sections:
|
|
236
|
+
return base
|
|
237
|
+
|
|
238
|
+
result = dict(base)
|
|
239
|
+
bash_rules = dict(result.get("bash", {}))
|
|
240
|
+
|
|
241
|
+
for section in sections:
|
|
242
|
+
if not isinstance(section, dict):
|
|
243
|
+
continue
|
|
244
|
+
for pattern, action in (section.get("shell") or {}).items():
|
|
245
|
+
bash_rules[pattern] = action
|
|
246
|
+
for tool, action in (section.get("tools") or {}).items():
|
|
247
|
+
result[tool] = action
|
|
248
|
+
|
|
249
|
+
result["bash"] = bash_rules
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build_config(provider: Provider, mode: str, *, permission_profile: str | None = None) -> dict[str, Any]:
|
|
254
|
+
profile_name = permission_profile or provider.permissions
|
|
255
|
+
profile = load_permission_profile(profile_name) if profile_name else None
|
|
256
|
+
agent_name = MODE_TO_AGENT[mode]
|
|
257
|
+
prompt_mode = "code" if mode == "resume" else mode
|
|
258
|
+
model = provider.model
|
|
259
|
+
permissions = _merge_permissions(agent_permissions(mode), profile, mode)
|
|
260
|
+
return {
|
|
261
|
+
"$schema": "https://opencode.ai/config.json",
|
|
262
|
+
"autoupdate": False,
|
|
263
|
+
"share": "disabled",
|
|
264
|
+
"model": model,
|
|
265
|
+
"small_model": model,
|
|
266
|
+
"default_agent": agent_name,
|
|
267
|
+
"enabled_providers": [provider.provider_id],
|
|
268
|
+
"provider": {
|
|
269
|
+
provider.provider_id: {
|
|
270
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
271
|
+
"name": provider.display_name,
|
|
272
|
+
"options": {"baseURL": provider.base_url},
|
|
273
|
+
"models": {
|
|
274
|
+
provider.model_id: {
|
|
275
|
+
"name": provider.display_name,
|
|
276
|
+
"limit": {
|
|
277
|
+
"context": provider.context_tokens,
|
|
278
|
+
"output": provider.output_tokens,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
"permission": {"*": "deny"},
|
|
285
|
+
"agent": {
|
|
286
|
+
agent_name: {
|
|
287
|
+
"description": f"Isolated {prompt_mode} worker controlled by the main planner",
|
|
288
|
+
"mode": "primary",
|
|
289
|
+
"model": model,
|
|
290
|
+
"steps": STEPS_BY_MODE[mode],
|
|
291
|
+
"prompt": load_prompt(mode),
|
|
292
|
+
"permission": permissions,
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Mode: code (edits allowed).
|
|
2
|
+
|
|
3
|
+
- Implement only the approved behavior. Make the smallest convention-aligned edits; follow the codebase's existing patterns and naming.
|
|
4
|
+
- If the task gives a file whitelist, never touch files outside it.
|
|
5
|
+
- Self-validate before finishing: run the checks the task specifies; if none are specified, use the project's standard test/lint commands. Do not finish with failing checks unless you can prove the failure is pre-existing — then include that proof in VALIDATION.
|
|
6
|
+
- Report problems you noticed but deliberately did not touch (out-of-scope, pre-existing, suspicious) in REMAINING_RISKS.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
You are an execution worker controlled by a main planner process (Codex or Claude). Follow the supplied task contract exactly. Do not invent requirements, broaden scope, delegate to subagents, access credentials, share the session, or contact unrelated network services. Inspect the named implementation paths before making claims. Fail visibly when a required action is blocked.
|
|
2
|
+
|
|
3
|
+
Workspace discipline:
|
|
4
|
+
|
|
5
|
+
- The workspace may contain pre-existing uncommitted changes. That is normal: do not explain them, do not revert them, and do not count them in your own change list.
|
|
6
|
+
- Stay inside the given work directory. Do not touch paths outside it.
|
|
7
|
+
|
|
8
|
+
Permission preview — these command classes are denied at the permission layer, in every mode. Do not attempt them; if the task seems to require one, stop and report it as blocked instead of retrying or working around it:
|
|
9
|
+
|
|
10
|
+
- Remote Git operations (`git push/pull/fetch/clone/remote add`, `gh`).
|
|
11
|
+
- Network clients (`curl`, `wget`, `ssh`, `scp`, `sftp`, `rsync`, `nc`).
|
|
12
|
+
- Package publishing (`npm/pnpm/yarn publish`).
|
|
13
|
+
- Credential paths (anything touching `auth.json` or `.env` files), `sudo`, and destructive root/home deletion.
|
|
14
|
+
|
|
15
|
+
A blocked call returns a permission error once; never retry it verbatim.
|
|
16
|
+
|
|
17
|
+
Final report — end with exactly these four sections:
|
|
18
|
+
|
|
19
|
+
1. `STATUS`: complete, incomplete, or blocked.
|
|
20
|
+
2. `FILES_CHANGED`: exact paths with a one-line purpose each, or `none`.
|
|
21
|
+
3. `VALIDATION`: the commands you ran and their verbatim key output (counts, failing test names, error text). Quote real output; never paraphrase it.
|
|
22
|
+
4. `REMAINING_RISKS`: unmet boundaries, assumptions, pre-existing problems you noticed but did not touch, or `none`.
|
|
23
|
+
|
|
24
|
+
The main planner will independently review and verify your work; your completion claim is not the acceptance decision.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Mode: explore (read-only; file edits are denied at the permission layer).
|
|
2
|
+
|
|
3
|
+
- Investigate read-only. Do not edit files; do not retry denied write attempts.
|
|
4
|
+
- Every conclusion must carry a `file:line` reference. A conclusion without a reference is invalid.
|
|
5
|
+
- Output structured items, one fact per item; no preamble, summaries, or commentary.
|
|
6
|
+
- Quote at most 3 lines of code per item; for anything longer give the `file:line` reference instead.
|
|
7
|
+
- Cap conclusions at 20 items unless the task sets a different budget; if you exceed the cap, list the most important ones and note how many more exist and in which directories.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Mode: review (read-only; file edits are denied at the permission layer).
|
|
2
|
+
|
|
3
|
+
- Review read-only. Do not edit files and do not fix anything.
|
|
4
|
+
- Report only substantiated findings: each finding needs a severity, an exact `file:line` location, the observed behavior, the concrete impact, and a specific fix direction.
|
|
5
|
+
- Do not pad the report with style opinions or unverified suspicions; if something is a hunch, label it as unverified in one line or drop it.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Mode: test (read-only for source; only test/lint/build and read-only commands are allowed).
|
|
2
|
+
|
|
3
|
+
- Validate without editing source files.
|
|
4
|
+
- Run only the approved checks from the task contract.
|
|
5
|
+
- Report complete failure output verbatim: full failing test names, assertion messages, and exit statuses — enough for the planner to diagnose without rerunning.
|
|
6
|
+
- Do not attempt fixes; diagnosis and fixing belong to the planner.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Provider registry: loads provider definitions from YAML files.
|
|
3
|
+
|
|
4
|
+
The single source of truth for provider routing is the `data/providers/`
|
|
5
|
+
directory inside this package. Each `.yaml` file defines one provider.
|
|
6
|
+
Adding a new model only requires dropping a new YAML file — no Python changes.
|
|
7
|
+
|
|
8
|
+
The shared path helpers also live here so that every other module imports
|
|
9
|
+
facts from one place.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import yaml # type: ignore[import-untyped]
|
|
21
|
+
except ImportError:
|
|
22
|
+
yaml = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MAX_TASK_BYTES = 512_000
|
|
26
|
+
|
|
27
|
+
PROVIDERS_DIR = Path(__file__).resolve().parent / "data" / "providers"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Provider:
|
|
32
|
+
key: str
|
|
33
|
+
provider_id: str
|
|
34
|
+
model_id: str
|
|
35
|
+
base_url: str
|
|
36
|
+
display_name: str
|
|
37
|
+
context_tokens: int
|
|
38
|
+
output_tokens: int
|
|
39
|
+
permissions: str | None = None
|
|
40
|
+
runner: str = "opencode"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def model(self) -> str:
|
|
44
|
+
return f"{self.provider_id}/{self.model_id}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_yaml(path: Path) -> dict[str, Any]:
|
|
48
|
+
"""Parse a YAML file, with a stdlib fallback for simple flat files."""
|
|
49
|
+
text = path.read_text(encoding="utf-8")
|
|
50
|
+
if yaml is not None:
|
|
51
|
+
return yaml.safe_load(text)
|
|
52
|
+
# Minimal fallback: flat key: value files without nesting.
|
|
53
|
+
result: dict[str, Any] = {}
|
|
54
|
+
for line in text.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or line.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
if ":" not in line:
|
|
59
|
+
continue
|
|
60
|
+
key, _, value = line.partition(":")
|
|
61
|
+
value = value.strip()
|
|
62
|
+
if value.isdigit():
|
|
63
|
+
result[key.strip()] = int(value)
|
|
64
|
+
else:
|
|
65
|
+
result[key.strip()] = value
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_providers(providers_dir: Path | None = None) -> dict[str, Provider]:
|
|
70
|
+
"""Discover and load all provider YAML files from the given directory."""
|
|
71
|
+
directory = providers_dir or PROVIDERS_DIR
|
|
72
|
+
if not directory.is_dir():
|
|
73
|
+
raise RuntimeError(f"providers directory does not exist: {directory}")
|
|
74
|
+
providers: dict[str, Provider] = {}
|
|
75
|
+
for path in sorted(directory.glob("*.yaml")):
|
|
76
|
+
data = _parse_yaml(path)
|
|
77
|
+
required = ("key", "provider_id", "model_id", "base_url", "display_name",
|
|
78
|
+
"context_tokens", "output_tokens")
|
|
79
|
+
missing = [f for f in required if f not in data]
|
|
80
|
+
if missing:
|
|
81
|
+
raise RuntimeError(f"provider {path.name} missing fields: {', '.join(missing)}")
|
|
82
|
+
provider = Provider(
|
|
83
|
+
key=data["key"],
|
|
84
|
+
provider_id=data["provider_id"],
|
|
85
|
+
model_id=data["model_id"],
|
|
86
|
+
base_url=data["base_url"],
|
|
87
|
+
display_name=data["display_name"],
|
|
88
|
+
context_tokens=int(data["context_tokens"]),
|
|
89
|
+
output_tokens=int(data["output_tokens"]),
|
|
90
|
+
permissions=data.get("permissions") or None,
|
|
91
|
+
runner=data.get("runner") or "opencode",
|
|
92
|
+
)
|
|
93
|
+
if provider.key in providers:
|
|
94
|
+
raise RuntimeError(f"duplicate provider key: {provider.key}")
|
|
95
|
+
providers[provider.key] = provider
|
|
96
|
+
if not providers:
|
|
97
|
+
raise RuntimeError(f"no provider YAML files found in {directory}")
|
|
98
|
+
return providers
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Eagerly loaded at import time for backwards compatibility with existing code
|
|
102
|
+
# that does `from providers import PROVIDERS`. Callers that need a custom
|
|
103
|
+
# directory can call load_providers() directly.
|
|
104
|
+
PROVIDERS = load_providers()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pilot_home() -> Path:
|
|
108
|
+
"""Root for all pilot-workers runtime data (credentials, logs, worktrees)."""
|
|
109
|
+
return Path(os.environ.get("PILOT_WORKERS_HOME",
|
|
110
|
+
os.environ.get("CODEX_HOME", Path.home() / ".codex"))).expanduser().resolve()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def workers_root() -> Path:
|
|
114
|
+
return pilot_home() / "opencode-workers"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def profile_root(provider: Provider) -> Path:
|
|
118
|
+
return workers_root() / "providers" / provider.key
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def profile_paths(provider: Provider) -> dict[str, Path]:
|
|
122
|
+
root = profile_root(provider)
|
|
123
|
+
return {
|
|
124
|
+
"root": root,
|
|
125
|
+
"config": root / "config",
|
|
126
|
+
"data": root / "data",
|
|
127
|
+
"state": root / "state",
|
|
128
|
+
"cache": root / "cache",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def logs_root(provider: Provider) -> Path:
|
|
133
|
+
return workers_root() / "logs" / provider.key
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def worktrees_root() -> Path:
|
|
137
|
+
return workers_root() / "worktrees"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Runner abstraction layer.
|
|
2
|
+
|
|
3
|
+
A runner is the execution carrier that takes a task contract and runs it
|
|
4
|
+
through a model. OpenCode is the first implementation; others (Aider,
|
|
5
|
+
Continue, etc.) can be added by implementing the same interface.
|
|
6
|
+
|
|
7
|
+
The registry below is the single entry point: callers ask for a runner by
|
|
8
|
+
name via ``get_runner`` and receive a singleton ``Runner`` instance.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pilot_workers.runners.base import (
|
|
12
|
+
Runner,
|
|
13
|
+
TokenUsage,
|
|
14
|
+
ToolCall,
|
|
15
|
+
UnifiedEvent,
|
|
16
|
+
)
|
|
17
|
+
from pilot_workers.runners.opencode_runner import OpenCodeRunner
|
|
18
|
+
|
|
19
|
+
RUNNERS: dict[str, Runner] = {"opencode": OpenCodeRunner()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_runner(name: str) -> Runner:
|
|
23
|
+
try:
|
|
24
|
+
return RUNNERS[name]
|
|
25
|
+
except KeyError:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
f"unknown runner: {name} (available: {sorted(RUNNERS)})"
|
|
28
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Runner abstraction: unified event model + Runner interface.
|
|
2
|
+
|
|
3
|
+
A runner is the execution carrier that takes a task contract and runs it
|
|
4
|
+
through a model. The unified event types below are the lingua franca that
|
|
5
|
+
dispatchers, renderers, and verdict logic consume; each concrete Runner
|
|
6
|
+
parses its engine's native events into this shape on the read side.
|
|
7
|
+
|
|
8
|
+
Design notes:
|
|
9
|
+
|
|
10
|
+
- `worker_runner.started/summary/heartbeat/verdict` are pilot-workers-owned
|
|
11
|
+
events; they bypass `parse_events` and go straight to rendering/verdict.
|
|
12
|
+
- `kind="step"` must fire exactly once per engine step; `build_config` must
|
|
13
|
+
hard-stop the engine at its step cap. STEPS_BY_MODE is currently calibrated
|
|
14
|
+
to OpenCode's step granularity and MUST be re-calibrated when a new runner
|
|
15
|
+
is added.
|
|
16
|
+
- Runners that do not support session resume MUST raise RuntimeError on a
|
|
17
|
+
non-None `session` argument to `build_command` rather than silently ignore.
|
|
18
|
+
- The on-disk JSONL always stores the runner's raw events; `parse_events`
|
|
19
|
+
only translates on the read side.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TokenUsage:
|
|
32
|
+
input: int = 0
|
|
33
|
+
output: int = 0
|
|
34
|
+
reasoning: int = 0
|
|
35
|
+
cache_read: int = 0
|
|
36
|
+
cache_write: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ToolCall:
|
|
41
|
+
name: str
|
|
42
|
+
status: str # runner-reported status, e.g. "completed" / "error"
|
|
43
|
+
input_brief: str # single-line human-readable summary, "" if none
|
|
44
|
+
output_brief: str # first informative output line, "" if none
|
|
45
|
+
error: str | None # error text when status == "error"
|
|
46
|
+
is_permission_denied: bool # whether the runner flagged a permission denial
|
|
47
|
+
silent_output: bool # True = renderer should hide the output line
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class UnifiedEvent:
|
|
52
|
+
kind: Literal["step", "text", "reasoning", "tool", "error", "session"]
|
|
53
|
+
ts: int | None = None # epoch milliseconds of the raw event, None if absent
|
|
54
|
+
text: str | None = None # text/reasoning payload
|
|
55
|
+
tokens: TokenUsage | None = None # step usage
|
|
56
|
+
tool: ToolCall | None = None # tool invocation info
|
|
57
|
+
session_id: str | None = None # session id for kind="session"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Runner(ABC):
|
|
61
|
+
"""Worker runner adapter.
|
|
62
|
+
|
|
63
|
+
Contract (required reading for new runner implementations):
|
|
64
|
+
|
|
65
|
+
- `worker_runner.started/summary/heartbeat/verdict` are pilot-workers-owned
|
|
66
|
+
events; they do not flow through parse_events and reach rendering and
|
|
67
|
+
verdict logic directly.
|
|
68
|
+
- `kind="step"` must fire exactly once per engine step; build_config must
|
|
69
|
+
make the engine hard-stop at its steps cap. STEPS_BY_MODE is currently
|
|
70
|
+
calibrated to OpenCode's step granularity and MUST be re-calibrated
|
|
71
|
+
when a new runner is wired in.
|
|
72
|
+
- Runners that do not support session resume MUST raise RuntimeError on a
|
|
73
|
+
non-None `session` argument; they MUST NOT silently ignore it.
|
|
74
|
+
- The on-disk JSONL always stores the runner's raw events; parse_events
|
|
75
|
+
only translates on the read side.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def build_config(self, provider: Any, mode: str, permission_profile: str | None = None) -> dict: ...
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def build_command(self, binary: Path, provider: Any, mode: str,
|
|
85
|
+
workdir: Path, run_id: str, session: str | None) -> list[str]: ...
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def runner_environment(self, provider: Any, config: dict) -> dict[str, str]:
|
|
89
|
+
"""Only the env vars specific to this runner; neutral parts
|
|
90
|
+
(SAFE_ENV_KEYS / XDG) are owned by the runtime layer."""
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def format_task_input(self, task: str, mode: str) -> str:
|
|
94
|
+
"""Wrap the task text into the engine's expected first-turn input
|
|
95
|
+
(delivered via stdin)."""
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def parse_events(self, raw: dict) -> list[UnifiedEvent]:
|
|
99
|
+
"""Translate one raw event into 0..n unified events.
|
|
100
|
+
Unrecognized events return []."""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def resolve_binary(self) -> Path:
|
|
104
|
+
"""Locate and verify the runner executable; raise RuntimeError if it
|
|
105
|
+
is missing or its version does not match."""
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def credential_path(self, provider: Any) -> Path: ...
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def credential_payload(self, provider: Any, key: str) -> dict:
|
|
112
|
+
"""Produce the credential-file payload structure; the actual file
|
|
113
|
+
write (atomic write / 0600 mode) is owned by the neutral layer."""
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def parse_credential(self, provider: Any, payload: dict) -> str:
|
|
117
|
+
"""Extract the API key from a credential-file payload; raise
|
|
118
|
+
RuntimeError if the shape does not match."""
|
|
119
|
+
|
|
120
|
+
def binary_path(self) -> Path | None:
|
|
121
|
+
"""Best-effort binary location WITHOUT verification (for dry-run display).
|
|
122
|
+
Default: None (unknown until resolve_binary)."""
|
|
123
|
+
return None
|