aicortexpy 0.1.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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/models.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Model list discovery for heartbeat pending_model_list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("aicortexpy.models")
|
|
16
|
+
|
|
17
|
+
_CACHE_TTL = 60.0
|
|
18
|
+
_cache_mu = threading.Lock()
|
|
19
|
+
_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
|
20
|
+
|
|
21
|
+
_STATIC: dict[str, list[dict[str, Any]]] = {
|
|
22
|
+
"claude": [
|
|
23
|
+
{"id": "sonnet", "label": "Claude Sonnet", "provider": "claude", "default": True},
|
|
24
|
+
{"id": "opus", "label": "Claude Opus", "provider": "claude"},
|
|
25
|
+
{"id": "haiku", "label": "Claude Haiku", "provider": "claude"},
|
|
26
|
+
],
|
|
27
|
+
"codex": [
|
|
28
|
+
{"id": "o4-mini", "label": "o4-mini", "provider": "codex", "default": True},
|
|
29
|
+
{"id": "gpt-5", "label": "GPT-5", "provider": "codex"},
|
|
30
|
+
],
|
|
31
|
+
"cursor": [
|
|
32
|
+
{"id": "auto", "label": "Auto", "provider": "cursor", "default": True},
|
|
33
|
+
],
|
|
34
|
+
"gemini": [
|
|
35
|
+
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "provider": "gemini", "default": True},
|
|
36
|
+
],
|
|
37
|
+
"copilot": [
|
|
38
|
+
{"id": "gpt-4.1", "label": "GPT-4.1", "provider": "copilot", "default": True},
|
|
39
|
+
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4", "provider": "copilot"},
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_ACP_PROVIDERS = frozenset({"hermes", "kimi", "kiro", "dsh", "copilot"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_models(provider: str, executable: str) -> list[dict[str, Any]]:
|
|
47
|
+
key = f"{provider}:{executable}"
|
|
48
|
+
now = time.time()
|
|
49
|
+
with _cache_mu:
|
|
50
|
+
hit = _cache.get(key)
|
|
51
|
+
if hit and now - hit[0] < _CACHE_TTL:
|
|
52
|
+
return list(hit[1])
|
|
53
|
+
|
|
54
|
+
models = _discover(provider, executable)
|
|
55
|
+
if not models:
|
|
56
|
+
models = list(_STATIC.get(provider, []))
|
|
57
|
+
with _cache_mu:
|
|
58
|
+
_cache[key] = (now, list(models))
|
|
59
|
+
return models
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def model_selection_supported(provider: str) -> bool:
|
|
63
|
+
return provider in _STATIC or provider in {
|
|
64
|
+
"claude",
|
|
65
|
+
"codex",
|
|
66
|
+
"cursor",
|
|
67
|
+
"gemini",
|
|
68
|
+
"copilot",
|
|
69
|
+
"opencode",
|
|
70
|
+
"pi",
|
|
71
|
+
"hermes",
|
|
72
|
+
"kimi",
|
|
73
|
+
"kiro",
|
|
74
|
+
"dsh",
|
|
75
|
+
"openclaw",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _discover(provider: str, executable: str) -> list[dict[str, Any]]:
|
|
80
|
+
if provider in ("claude", "codex", "gemini"):
|
|
81
|
+
return list(_STATIC.get(provider, []))
|
|
82
|
+
if provider == "opencode":
|
|
83
|
+
return _probe_args(executable, ["models"], provider) or _probe_cli(provider, executable)
|
|
84
|
+
if provider == "pi":
|
|
85
|
+
return _probe_args(executable, ["--list-models"], provider) or _probe_cli(
|
|
86
|
+
provider, executable
|
|
87
|
+
)
|
|
88
|
+
if provider == "cursor":
|
|
89
|
+
return _probe_args(executable, ["--list-models"], provider) or _probe_cli(
|
|
90
|
+
provider, executable
|
|
91
|
+
)
|
|
92
|
+
if provider == "openclaw":
|
|
93
|
+
return _discover_openclaw_agents(executable)
|
|
94
|
+
if provider in _ACP_PROVIDERS:
|
|
95
|
+
acp_args = _acp_args_for(provider)
|
|
96
|
+
extra_env = {"HERMES_YOLO_MODE": "1"} if provider == "hermes" else None
|
|
97
|
+
parse = _parse_dsh_session_models if provider == "dsh" else _parse_acp_session_models
|
|
98
|
+
models = discover_acp_models(executable, acp_args, extra_env=extra_env, parse_fn=parse)
|
|
99
|
+
if models:
|
|
100
|
+
return models
|
|
101
|
+
if provider == "copilot":
|
|
102
|
+
return list(_STATIC["copilot"])
|
|
103
|
+
return []
|
|
104
|
+
return _probe_cli(provider, executable)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _acp_args_for(provider: str) -> list[str]:
|
|
108
|
+
if provider == "copilot":
|
|
109
|
+
return ["--acp"]
|
|
110
|
+
if provider == "dsh":
|
|
111
|
+
return ["--profile", "acp"]
|
|
112
|
+
if provider == "kiro":
|
|
113
|
+
return ["acp"]
|
|
114
|
+
return ["acp"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def discover_acp_models(
|
|
118
|
+
executable: str,
|
|
119
|
+
acp_args: list[str],
|
|
120
|
+
*,
|
|
121
|
+
extra_env: dict[str, str] | None = None,
|
|
122
|
+
parse_fn: Callable[[dict[str, Any], str], list[dict[str, Any]]] | None = None,
|
|
123
|
+
provider: str = "",
|
|
124
|
+
) -> list[dict[str, Any]]:
|
|
125
|
+
"""Spawn ACP, initialize + session/new, parse availableModels (Go discoverACPModels)."""
|
|
126
|
+
parse_fn = parse_fn or _parse_acp_session_models
|
|
127
|
+
env = os.environ.copy()
|
|
128
|
+
if extra_env:
|
|
129
|
+
env.update(extra_env)
|
|
130
|
+
try:
|
|
131
|
+
proc = subprocess.Popen(
|
|
132
|
+
[executable, *acp_args],
|
|
133
|
+
stdin=subprocess.PIPE,
|
|
134
|
+
stdout=subprocess.PIPE,
|
|
135
|
+
stderr=subprocess.DEVNULL,
|
|
136
|
+
text=True,
|
|
137
|
+
env=env,
|
|
138
|
+
bufsize=1,
|
|
139
|
+
)
|
|
140
|
+
except OSError as e:
|
|
141
|
+
log.debug("acp model discovery spawn failed: %s", e)
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
cwd = tempfile.gettempdir()
|
|
145
|
+
reqs = [
|
|
146
|
+
{
|
|
147
|
+
"jsonrpc": "2.0",
|
|
148
|
+
"id": 1,
|
|
149
|
+
"method": "initialize",
|
|
150
|
+
"params": {
|
|
151
|
+
"protocolVersion": 1,
|
|
152
|
+
"clientInfo": {"name": "aicortex-model-discovery", "version": "0.1.0"},
|
|
153
|
+
"clientCapabilities": {},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"jsonrpc": "2.0",
|
|
158
|
+
"id": 2,
|
|
159
|
+
"method": "session/new",
|
|
160
|
+
"params": {"cwd": cwd, "mcpServers": []},
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
try:
|
|
164
|
+
assert proc.stdin is not None
|
|
165
|
+
for req in reqs:
|
|
166
|
+
proc.stdin.write(json.dumps(req) + "\n")
|
|
167
|
+
proc.stdin.flush()
|
|
168
|
+
except OSError:
|
|
169
|
+
_kill(proc)
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
deadline = time.time() + 12.0
|
|
173
|
+
result_obj: dict[str, Any] | None = None
|
|
174
|
+
try:
|
|
175
|
+
assert proc.stdout is not None
|
|
176
|
+
while time.time() < deadline:
|
|
177
|
+
line = proc.stdout.readline()
|
|
178
|
+
if not line:
|
|
179
|
+
if proc.poll() is not None:
|
|
180
|
+
break
|
|
181
|
+
time.sleep(0.05)
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
msg = json.loads(line)
|
|
185
|
+
except json.JSONDecodeError:
|
|
186
|
+
continue
|
|
187
|
+
if not isinstance(msg, dict):
|
|
188
|
+
continue
|
|
189
|
+
if msg.get("id") == 2 and "result" in msg:
|
|
190
|
+
result_obj = msg.get("result") if isinstance(msg.get("result"), dict) else {}
|
|
191
|
+
break
|
|
192
|
+
finally:
|
|
193
|
+
_kill(proc)
|
|
194
|
+
|
|
195
|
+
if not isinstance(result_obj, dict):
|
|
196
|
+
return []
|
|
197
|
+
return parse_fn(result_obj, provider or "acp")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _kill(proc: subprocess.Popen) -> None:
|
|
201
|
+
try:
|
|
202
|
+
if proc.stdin:
|
|
203
|
+
proc.stdin.close()
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
try:
|
|
207
|
+
proc.kill()
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
try:
|
|
211
|
+
proc.wait(timeout=2)
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_acp_session_models(result: dict[str, Any], provider: str) -> list[dict[str, Any]]:
|
|
217
|
+
models_obj = result.get("models")
|
|
218
|
+
available: list[Any] = []
|
|
219
|
+
current = ""
|
|
220
|
+
if isinstance(models_obj, dict):
|
|
221
|
+
available = models_obj.get("availableModels") or []
|
|
222
|
+
current = str(models_obj.get("currentModelId") or "")
|
|
223
|
+
elif isinstance(result.get("availableModels"), list):
|
|
224
|
+
available = result["availableModels"]
|
|
225
|
+
current = str(result.get("currentModelId") or "")
|
|
226
|
+
out: list[dict[str, Any]] = []
|
|
227
|
+
for i, m in enumerate(available):
|
|
228
|
+
if not isinstance(m, dict):
|
|
229
|
+
continue
|
|
230
|
+
mid = str(m.get("modelId") or m.get("id") or "").strip()
|
|
231
|
+
if not mid:
|
|
232
|
+
continue
|
|
233
|
+
label = str(m.get("name") or m.get("label") or mid)
|
|
234
|
+
out.append(
|
|
235
|
+
{
|
|
236
|
+
"id": mid,
|
|
237
|
+
"label": label,
|
|
238
|
+
"provider": provider or "acp",
|
|
239
|
+
"default": (current and mid == current) or (not current and i == 0),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_dsh_session_models(result: dict[str, Any], provider: str) -> list[dict[str, Any]]:
|
|
246
|
+
opts = result.get("configOptions")
|
|
247
|
+
if not isinstance(opts, list):
|
|
248
|
+
return _parse_acp_session_models(result, provider)
|
|
249
|
+
for opt in opts:
|
|
250
|
+
if not isinstance(opt, dict):
|
|
251
|
+
continue
|
|
252
|
+
if str(opt.get("id") or "") != "model":
|
|
253
|
+
continue
|
|
254
|
+
choices = opt.get("options") or opt.get("values") or []
|
|
255
|
+
out: list[dict[str, Any]] = []
|
|
256
|
+
current = str(opt.get("value") or "")
|
|
257
|
+
if isinstance(choices, list):
|
|
258
|
+
for i, c in enumerate(choices):
|
|
259
|
+
if isinstance(c, dict):
|
|
260
|
+
mid = str(c.get("value") or c.get("id") or "").strip()
|
|
261
|
+
label = str(c.get("label") or c.get("name") or mid)
|
|
262
|
+
else:
|
|
263
|
+
mid = str(c).strip()
|
|
264
|
+
label = mid
|
|
265
|
+
if not mid:
|
|
266
|
+
continue
|
|
267
|
+
out.append(
|
|
268
|
+
{
|
|
269
|
+
"id": mid,
|
|
270
|
+
"label": label,
|
|
271
|
+
"provider": provider or "dsh",
|
|
272
|
+
"default": (current and mid == current) or (not current and i == 0),
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
return out
|
|
276
|
+
return _parse_acp_session_models(result, provider)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _discover_openclaw_agents(executable: str) -> list[dict[str, Any]]:
|
|
280
|
+
for args in (["agents", "list", "--json"], ["agents", "list"]):
|
|
281
|
+
try:
|
|
282
|
+
proc = subprocess.run(
|
|
283
|
+
[executable, *args],
|
|
284
|
+
capture_output=True,
|
|
285
|
+
text=True,
|
|
286
|
+
timeout=20,
|
|
287
|
+
check=False,
|
|
288
|
+
)
|
|
289
|
+
except Exception:
|
|
290
|
+
continue
|
|
291
|
+
out = (proc.stdout or "").strip()
|
|
292
|
+
if proc.returncode != 0 or not out:
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(out)
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
continue
|
|
298
|
+
entries = data if isinstance(data, list) else (data.get("agents") if isinstance(data, dict) else None)
|
|
299
|
+
if not isinstance(entries, list):
|
|
300
|
+
continue
|
|
301
|
+
models: list[dict[str, Any]] = []
|
|
302
|
+
for i, e in enumerate(entries):
|
|
303
|
+
if isinstance(e, dict):
|
|
304
|
+
mid = str(e.get("id") or e.get("name") or "").strip()
|
|
305
|
+
label = str(e.get("name") or e.get("id") or mid)
|
|
306
|
+
else:
|
|
307
|
+
mid = str(e).strip()
|
|
308
|
+
label = mid
|
|
309
|
+
if not mid:
|
|
310
|
+
continue
|
|
311
|
+
models.append(
|
|
312
|
+
{"id": mid, "label": label, "provider": "openclaw", "default": i == 0}
|
|
313
|
+
)
|
|
314
|
+
if models:
|
|
315
|
+
return models
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _probe_args(executable: str, args: list[str], provider: str) -> list[dict[str, Any]]:
|
|
320
|
+
try:
|
|
321
|
+
proc = subprocess.run(
|
|
322
|
+
[executable, *args],
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
timeout=20,
|
|
326
|
+
check=False,
|
|
327
|
+
)
|
|
328
|
+
except Exception:
|
|
329
|
+
return []
|
|
330
|
+
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
|
331
|
+
if proc.returncode != 0 or not out.strip():
|
|
332
|
+
return []
|
|
333
|
+
return _parse_model_lines(out, provider)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _probe_cli(provider: str, executable: str) -> list[dict[str, Any]]:
|
|
337
|
+
for args in (
|
|
338
|
+
[executable, "models"],
|
|
339
|
+
[executable, "--list-models"],
|
|
340
|
+
[executable, "model", "list"],
|
|
341
|
+
):
|
|
342
|
+
try:
|
|
343
|
+
proc = subprocess.run(
|
|
344
|
+
args,
|
|
345
|
+
capture_output=True,
|
|
346
|
+
text=True,
|
|
347
|
+
timeout=20,
|
|
348
|
+
check=False,
|
|
349
|
+
)
|
|
350
|
+
except Exception:
|
|
351
|
+
continue
|
|
352
|
+
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
|
353
|
+
if proc.returncode != 0 or not out.strip():
|
|
354
|
+
continue
|
|
355
|
+
models = _parse_model_lines(out, provider)
|
|
356
|
+
if models:
|
|
357
|
+
return models
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parse_model_lines(text: str, provider: str) -> list[dict[str, Any]]:
|
|
362
|
+
models: list[dict[str, Any]] = []
|
|
363
|
+
seen: set[str] = set()
|
|
364
|
+
for line in text.splitlines():
|
|
365
|
+
line = line.strip().strip("-*• ")
|
|
366
|
+
if not line or line.lower().startswith("model"):
|
|
367
|
+
continue
|
|
368
|
+
m = re.match(r"([A-Za-z0-9._:/-]+)", line)
|
|
369
|
+
if not m:
|
|
370
|
+
continue
|
|
371
|
+
mid = m.group(1)
|
|
372
|
+
if mid.lower() in {"id", "name", "models"} or mid in seen:
|
|
373
|
+
continue
|
|
374
|
+
seen.add(mid)
|
|
375
|
+
models.append(
|
|
376
|
+
{
|
|
377
|
+
"id": mid,
|
|
378
|
+
"label": line if len(line) < 80 else mid,
|
|
379
|
+
"provider": provider,
|
|
380
|
+
"default": len(models) == 0,
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
if len(models) >= 40:
|
|
384
|
+
break
|
|
385
|
+
return models
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Per-task OpenClaw config wrapper (OPENCLAW_CONFIG_PATH + workspace pin)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("aicortexpy.openclaw_config")
|
|
15
|
+
|
|
16
|
+
OPENCLAW_CONFIG_FILE = "openclaw-config.json"
|
|
17
|
+
CLI_TIMEOUT = 5.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenclawConfigError(RuntimeError):
|
|
21
|
+
"""Fail-closed prepare error (matches Go execenv prepareOpenclawConfig)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class OpenclawConfigResult:
|
|
26
|
+
config_path: str = ""
|
|
27
|
+
include_root: str = ""
|
|
28
|
+
env_updates: dict[str, str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def prepare_openclaw_config(
|
|
32
|
+
env_root: Path,
|
|
33
|
+
work_dir: Path,
|
|
34
|
+
*,
|
|
35
|
+
openclaw_bin: str = "",
|
|
36
|
+
) -> OpenclawConfigResult:
|
|
37
|
+
"""Write env_root/openclaw-config.json pinning agents.*.workspace to work_dir."""
|
|
38
|
+
bin_name = openclaw_bin or shutil.which("openclaw") or "openclaw"
|
|
39
|
+
active_path, exists = _active_config_path(bin_name)
|
|
40
|
+
|
|
41
|
+
work = str(work_dir.resolve())
|
|
42
|
+
agents_list = _resolved_agents_list(bin_name) if exists else []
|
|
43
|
+
rewritten = _rewrite_agents_list_workspaces(agents_list, work)
|
|
44
|
+
|
|
45
|
+
wrapper: dict = {
|
|
46
|
+
"agents": {
|
|
47
|
+
"defaults": {"workspace": work},
|
|
48
|
+
"list": rewritten,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
include_root = ""
|
|
52
|
+
if exists and active_path:
|
|
53
|
+
wrapper["$include"] = [active_path]
|
|
54
|
+
include_root = str(Path(active_path).resolve().parent)
|
|
55
|
+
|
|
56
|
+
env_root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
out_path = env_root / OPENCLAW_CONFIG_FILE
|
|
58
|
+
out_path.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
env_updates = {"OPENCLAW_CONFIG_PATH": str(out_path.resolve())}
|
|
61
|
+
if include_root:
|
|
62
|
+
prev = os.environ.get("OPENCLAW_INCLUDE_ROOTS", "")
|
|
63
|
+
roots = [include_root] + ([p for p in prev.split(os.pathsep) if p] if prev else [])
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
ordered: list[str] = []
|
|
66
|
+
for r in roots:
|
|
67
|
+
if r not in seen:
|
|
68
|
+
seen.add(r)
|
|
69
|
+
ordered.append(r)
|
|
70
|
+
env_updates["OPENCLAW_INCLUDE_ROOTS"] = os.pathsep.join(ordered)
|
|
71
|
+
|
|
72
|
+
log.info("openclaw config written path=%s include_root=%s", out_path, include_root or "-")
|
|
73
|
+
return OpenclawConfigResult(
|
|
74
|
+
config_path=str(out_path),
|
|
75
|
+
include_root=include_root,
|
|
76
|
+
env_updates=env_updates,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(bin_name: str, args: list[str]) -> str:
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
[bin_name, *args],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
timeout=CLI_TIMEOUT,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
raise OpenclawConfigError(f"openclaw executable not found: {bin_name}") from e
|
|
91
|
+
except subprocess.TimeoutExpired as e:
|
|
92
|
+
raise OpenclawConfigError(f"openclaw {' '.join(args)} timed out") from e
|
|
93
|
+
except OSError as e:
|
|
94
|
+
raise OpenclawConfigError(f"openclaw cli failed: {e}") from e
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
err = (proc.stderr or proc.stdout or "").strip()[:500]
|
|
97
|
+
raise OpenclawConfigError(
|
|
98
|
+
f"openclaw {' '.join(args)} exited {proc.returncode}: {err or 'no output'}"
|
|
99
|
+
)
|
|
100
|
+
return (proc.stdout or "").strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _active_config_path(bin_name: str) -> tuple[str, bool]:
|
|
104
|
+
try:
|
|
105
|
+
out = _run(bin_name, ["config", "file"])
|
|
106
|
+
except OpenclawConfigError:
|
|
107
|
+
default = Path.home() / ".openclaw" / "openclaw.json"
|
|
108
|
+
if default.is_file():
|
|
109
|
+
return str(default), True
|
|
110
|
+
# Fresh install: allow minimal wrapper with no $include.
|
|
111
|
+
return str(default), False
|
|
112
|
+
path = out.splitlines()[0].strip()
|
|
113
|
+
if not path:
|
|
114
|
+
default = Path.home() / ".openclaw" / "openclaw.json"
|
|
115
|
+
return str(default), default.is_file()
|
|
116
|
+
p = Path(path)
|
|
117
|
+
if not p.is_file():
|
|
118
|
+
# Path reported but missing → fresh install; fail-open to minimal wrapper.
|
|
119
|
+
return path, False
|
|
120
|
+
return path, True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolved_agents_list(bin_name: str) -> list[dict]:
|
|
124
|
+
out = _run(bin_name, ["config", "get", "agents.list", "--json"])
|
|
125
|
+
if not out:
|
|
126
|
+
return []
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(out)
|
|
129
|
+
except json.JSONDecodeError as e:
|
|
130
|
+
raise OpenclawConfigError(f"openclaw agents.list JSON invalid: {e}") from e
|
|
131
|
+
if data is None:
|
|
132
|
+
return []
|
|
133
|
+
if not isinstance(data, list):
|
|
134
|
+
raise OpenclawConfigError("openclaw agents.list is not a JSON array")
|
|
135
|
+
entries: list[dict] = []
|
|
136
|
+
for item in data:
|
|
137
|
+
if isinstance(item, dict):
|
|
138
|
+
entries.append(item)
|
|
139
|
+
elif isinstance(item, str) and item.strip():
|
|
140
|
+
entries.append({"id": item.strip()})
|
|
141
|
+
return entries
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _rewrite_agents_list_workspaces(entries: list[dict], work: str) -> list[dict]:
|
|
145
|
+
"""Deep-copy agent entries and pin workspace (Go rewriteAgentsListWorkspaces)."""
|
|
146
|
+
out: list[dict] = []
|
|
147
|
+
for entry in entries:
|
|
148
|
+
copied = deepcopy(entry)
|
|
149
|
+
if not isinstance(copied, dict):
|
|
150
|
+
continue
|
|
151
|
+
copied["workspace"] = work
|
|
152
|
+
out.append(copied)
|
|
153
|
+
return out
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared project workdir layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .client import Task
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("aicortexpy.pinned")
|
|
13
|
+
|
|
14
|
+
STAGE_DIRS = ("docs", "design", "app", "test", "release")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def project_uses_pinned_workdir(task: Task) -> bool:
|
|
18
|
+
return bool(task.project_id.strip())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_local_path(resources: list[dict[str, Any]]) -> str:
|
|
22
|
+
for r in resources:
|
|
23
|
+
if r.get("resource_type") != "local_path":
|
|
24
|
+
continue
|
|
25
|
+
ref = r.get("resource_ref")
|
|
26
|
+
if isinstance(ref, str):
|
|
27
|
+
try:
|
|
28
|
+
ref = json.loads(ref)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
continue
|
|
31
|
+
if isinstance(ref, dict) and ref.get("path"):
|
|
32
|
+
return str(ref["path"])
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pinned_workdir_path(workspaces_root: Path, workspace_id: str, project_id: str) -> Path:
|
|
37
|
+
return workspaces_root / workspace_id / "projects" / project_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ensure_project_layout(root: Path) -> None:
|
|
41
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
for name in STAGE_DIRS:
|
|
43
|
+
(root / name).mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def prepare_pinned_workdir(
|
|
47
|
+
workspaces_root: Path,
|
|
48
|
+
task: Task,
|
|
49
|
+
) -> Path:
|
|
50
|
+
local = extract_local_path(task.project_resources)
|
|
51
|
+
if local:
|
|
52
|
+
root = Path(local)
|
|
53
|
+
ensure_project_layout(root)
|
|
54
|
+
log.info("using local_path project workdir %s", root)
|
|
55
|
+
return root
|
|
56
|
+
root = pinned_workdir_path(
|
|
57
|
+
workspaces_root, task.workspace_id or "default", task.project_id
|
|
58
|
+
)
|
|
59
|
+
ensure_project_layout(root)
|
|
60
|
+
return root
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Cross-platform helpers shared by daemon / sandbox / process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
HostOS = Literal["windows", "macos", "linux", "unknown"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def host_os() -> HostOS:
|
|
15
|
+
if sys.platform == "win32":
|
|
16
|
+
return "windows"
|
|
17
|
+
if sys.platform == "darwin":
|
|
18
|
+
return "macos"
|
|
19
|
+
if sys.platform.startswith("linux"):
|
|
20
|
+
return "linux"
|
|
21
|
+
return "unknown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_wsl() -> bool:
|
|
25
|
+
if host_os() != "linux":
|
|
26
|
+
return False
|
|
27
|
+
try:
|
|
28
|
+
data = open("/proc/version", encoding="utf-8").read().lower()
|
|
29
|
+
return "microsoft" in data or "wsl" in data
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def which(cmd: str) -> str | None:
|
|
35
|
+
return shutil.which(cmd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_bubblewrap() -> bool:
|
|
39
|
+
return which("bwrap") is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def has_sandbox_exec() -> bool:
|
|
43
|
+
return host_os() == "macos" and which("sandbox-exec") is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def platform_label() -> str:
|
|
47
|
+
bits = [
|
|
48
|
+
host_os(),
|
|
49
|
+
platform.machine(),
|
|
50
|
+
f"py{sys.version_info.major}.{sys.version_info.minor}",
|
|
51
|
+
]
|
|
52
|
+
if is_wsl():
|
|
53
|
+
bits.insert(1, "wsl")
|
|
54
|
+
return "/".join(bits)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def env_truthy(name: str, default: bool = False) -> bool:
|
|
58
|
+
raw = os.environ.get(name)
|
|
59
|
+
if raw is None:
|
|
60
|
+
return default
|
|
61
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
aicortexpy/poisoned.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Classify poisoned agent outputs/errors (aligned with Go poisoned.go)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
POISONED_OUTPUT_MAX_LEN = 320
|
|
6
|
+
|
|
7
|
+
FAILURE_ITERATION_LIMIT = "iteration_limit"
|
|
8
|
+
FAILURE_AGENT_FALLBACK = "agent_fallback_message"
|
|
9
|
+
FAILURE_API_INVALID = "api_invalid_request"
|
|
10
|
+
|
|
11
|
+
_MARKERS = (
|
|
12
|
+
("i reached the iteration limit", FAILURE_ITERATION_LIMIT),
|
|
13
|
+
("put your final update inside the content string", FAILURE_AGENT_FALLBACK),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def classify_poisoned_output(output: str) -> tuple[str, bool]:
|
|
18
|
+
trimmed = (output or "").strip()
|
|
19
|
+
if not trimmed or len(trimmed) > POISONED_OUTPUT_MAX_LEN:
|
|
20
|
+
return "", False
|
|
21
|
+
lowered = trimmed.lower()
|
|
22
|
+
for substr, reason in _MARKERS:
|
|
23
|
+
if substr in lowered:
|
|
24
|
+
return reason, True
|
|
25
|
+
return "", False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def classify_poisoned_error(err: str) -> tuple[str, bool]:
|
|
29
|
+
lowered = (err or "").lower()
|
|
30
|
+
needles = (
|
|
31
|
+
"invalid_request_error",
|
|
32
|
+
"invalid request",
|
|
33
|
+
"prompt is too long",
|
|
34
|
+
"context_length_exceeded",
|
|
35
|
+
"image exceeds",
|
|
36
|
+
"could not process image",
|
|
37
|
+
)
|
|
38
|
+
if any(n in lowered for n in needles) and (
|
|
39
|
+
"400" in lowered or "invalid" in lowered or "exceed" in lowered
|
|
40
|
+
):
|
|
41
|
+
return FAILURE_API_INVALID, True
|
|
42
|
+
if "invalid_request_error" in lowered:
|
|
43
|
+
return FAILURE_API_INVALID, True
|
|
44
|
+
return "", False
|