@pushpalsdev/cli 1.0.18 → 1.0.19
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.
- package/dist/pushpals-cli.js +277 -12
- package/package.json +1 -1
- package/runtime/sandbox/apps/workerpals/.python-version +1 -0
- package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
- package/runtime/sandbox/apps/workerpals/package.json +25 -0
- package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
- package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +111 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
- package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
- package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
- package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
- package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
- package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
- package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
- package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
- package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
- package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
- package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
- package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
- package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
- package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
- package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
- package/runtime/sandbox/bun.lock +2591 -0
- package/runtime/sandbox/configs/backend.toml +79 -0
- package/runtime/sandbox/configs/default.toml +260 -0
- package/runtime/sandbox/configs/dev.toml +2 -0
- package/runtime/sandbox/configs/local.example.toml +129 -0
- package/runtime/sandbox/package.json +65 -0
- package/runtime/sandbox/packages/protocol/README.md +168 -0
- package/runtime/sandbox/packages/protocol/package.json +37 -0
- package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
- package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
- package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
- package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
- package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
- package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
- package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
- package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
- package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
- package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
- package/runtime/sandbox/packages/shared/package.json +19 -0
- package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
- package/runtime/sandbox/packages/shared/src/client_preflight.ts +297 -0
- package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
- package/runtime/sandbox/packages/shared/src/config.ts +2201 -0
- package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
- package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
- package/runtime/sandbox/packages/shared/src/index.ts +100 -0
- package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
- package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +329 -0
- package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
- package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
- package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
- package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
- package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
- package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
- package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
- package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
- package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
- package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
- package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared infrastructure for PushPals executor scripts.
|
|
3
|
+
|
|
4
|
+
Both ``miniswe_executor.py`` and ``openhands_executor.py`` (and any future
|
|
5
|
+
executors) import from here instead of duplicating config loading, LLM
|
|
6
|
+
resolution, result emission, payload decoding, git helpers, etc.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import tomllib
|
|
23
|
+
except Exception: # pragma: no cover - python <3.11 fallback
|
|
24
|
+
tomllib = None # type: ignore[assignment]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ─── Constants ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
RESULT_PREFIX = "__PUSHPALS_OH_RESULT__ "
|
|
30
|
+
|
|
31
|
+
KNOWN_LITELLM_PROVIDER_PREFIXES: Set[str] = {
|
|
32
|
+
"openai",
|
|
33
|
+
"azure",
|
|
34
|
+
"ollama",
|
|
35
|
+
"openrouter",
|
|
36
|
+
"anthropic",
|
|
37
|
+
"google",
|
|
38
|
+
"gemini",
|
|
39
|
+
"vertex_ai",
|
|
40
|
+
"bedrock",
|
|
41
|
+
"cohere",
|
|
42
|
+
"groq",
|
|
43
|
+
"mistral",
|
|
44
|
+
"huggingface",
|
|
45
|
+
"replicate",
|
|
46
|
+
"deepseek",
|
|
47
|
+
"xai",
|
|
48
|
+
"together_ai",
|
|
49
|
+
"fireworks_ai",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
DEFAULT_TOOLCALL_RETRY_MAX = 1
|
|
53
|
+
|
|
54
|
+
# Superset of signals from both executors indicating the model failed to
|
|
55
|
+
# emit tool calls / tool actions.
|
|
56
|
+
NO_TOOL_CALL_SIGNALS: Tuple[str, ...] = (
|
|
57
|
+
"no tool calls found",
|
|
58
|
+
"no tool call found",
|
|
59
|
+
"no function calls found",
|
|
60
|
+
"no function call found",
|
|
61
|
+
"tool_calls",
|
|
62
|
+
"function_call",
|
|
63
|
+
"did not call any tools",
|
|
64
|
+
"didn't call any tools",
|
|
65
|
+
"tool use required",
|
|
66
|
+
"must use tools",
|
|
67
|
+
"no actions found",
|
|
68
|
+
"no action found",
|
|
69
|
+
"no tool messages",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ─── Core helpers ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def emit(result: Dict[str, Any]) -> None:
|
|
75
|
+
"""Write a structured result line that the TS host parses."""
|
|
76
|
+
sys.stdout.write(f"{RESULT_PREFIX}{json.dumps(result, ensure_ascii=True)}\n")
|
|
77
|
+
sys.stdout.flush()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def executor_log(message: str) -> None:
|
|
81
|
+
line = message if message.endswith("\n") else f"{message}\n"
|
|
82
|
+
sys.stdout.write(line)
|
|
83
|
+
sys.stdout.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _debug_enabled() -> bool:
|
|
87
|
+
return os.environ.get("WORKERPALS_DEBUG", "").strip().lower() in {"1", "true", "yes"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Logger:
|
|
91
|
+
"""Simple levelled logger for executor scripts.
|
|
92
|
+
|
|
93
|
+
Usage::
|
|
94
|
+
|
|
95
|
+
log = Logger("[MiniSweExecutor]")
|
|
96
|
+
log.info("Starting execution")
|
|
97
|
+
log.debug("Instruction: ...") # only when WORKERPALS_DEBUG=1
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, prefix: str) -> None:
|
|
101
|
+
self.prefix = prefix
|
|
102
|
+
|
|
103
|
+
def info(self, message: str) -> None:
|
|
104
|
+
executor_log(f"{self.prefix} {message}")
|
|
105
|
+
|
|
106
|
+
def debug(self, message: str) -> None:
|
|
107
|
+
if _debug_enabled():
|
|
108
|
+
executor_log(f"{self.prefix} {message}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fail(summary: str, stderr: Optional[str] = None, exit_code: int = 1) -> int:
|
|
112
|
+
"""Emit a failure result and return the exit code."""
|
|
113
|
+
emit({"ok": False, "summary": summary, "stderr": stderr or "", "exitCode": exit_code})
|
|
114
|
+
return exit_code
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode_payload(raw: str) -> Dict[str, Any]:
|
|
118
|
+
decoded = base64.b64decode(raw).decode("utf-8")
|
|
119
|
+
payload = json.loads(decoded)
|
|
120
|
+
if not isinstance(payload, dict):
|
|
121
|
+
raise ValueError("payload must be a JSON object")
|
|
122
|
+
return payload
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_repo_within_assigned_root(repo: str) -> Tuple[Optional[str], Optional[str]]:
|
|
126
|
+
raw_repo = str(repo or "").strip()
|
|
127
|
+
if not raw_repo:
|
|
128
|
+
return None, "Invalid payload: missing 'repo'"
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
repo_path = Path(raw_repo).resolve()
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
return None, f"Invalid payload repo path: {exc}"
|
|
134
|
+
|
|
135
|
+
if not repo_path.exists() or not repo_path.is_dir():
|
|
136
|
+
return None, f"Invalid payload repo path: not a directory ({repo_path})"
|
|
137
|
+
|
|
138
|
+
assigned_raw = (os.environ.get("PUSHPALS_ASSIGNED_REPO_ROOT") or "").strip()
|
|
139
|
+
if assigned_raw:
|
|
140
|
+
try:
|
|
141
|
+
assigned_root = Path(assigned_raw).resolve()
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
return None, f"Invalid assigned repo root: {exc}"
|
|
144
|
+
if repo_path != assigned_root and assigned_root not in repo_path.parents:
|
|
145
|
+
return (
|
|
146
|
+
None,
|
|
147
|
+
"Refusing repo path outside assigned root: "
|
|
148
|
+
f"repo={repo_path} assigned_root={assigned_root}",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return str(repo_path), None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def to_int(value: Any, default: int) -> int:
|
|
155
|
+
try:
|
|
156
|
+
return int(value)
|
|
157
|
+
except Exception:
|
|
158
|
+
return default
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def to_float(value: Any, default: float) -> float:
|
|
162
|
+
try:
|
|
163
|
+
return float(value)
|
|
164
|
+
except Exception:
|
|
165
|
+
return default
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def to_single_line(value: Any, max_chars: int = 240) -> str:
|
|
169
|
+
text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
|
|
170
|
+
if not text:
|
|
171
|
+
return ""
|
|
172
|
+
if len(text) <= max_chars:
|
|
173
|
+
return text
|
|
174
|
+
return text[: max(1, max_chars - 3)] + "..."
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_no_tool_calls_error(exc: Exception) -> bool:
|
|
178
|
+
lowered = str(exc).lower()
|
|
179
|
+
return any(sig in lowered for sig in NO_TOOL_CALL_SIGNALS)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ─── Config loading (TOML + env) ────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
|
185
|
+
_MISSING = object()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
189
|
+
out = dict(base)
|
|
190
|
+
for key, value in override.items():
|
|
191
|
+
existing = out.get(key)
|
|
192
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
193
|
+
out[key] = _deep_merge(existing, value)
|
|
194
|
+
else:
|
|
195
|
+
out[key] = value
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def repo_root_for_runtime_config() -> Path:
|
|
200
|
+
explicit = (os.environ.get("PUSHPALS_REPO_PATH") or "").strip()
|
|
201
|
+
if explicit:
|
|
202
|
+
return Path(explicit)
|
|
203
|
+
return Path(__file__).resolve().parents[3]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_toml_file(path: Path) -> Dict[str, Any]:
|
|
207
|
+
if not path.exists() or not tomllib:
|
|
208
|
+
return {}
|
|
209
|
+
try:
|
|
210
|
+
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
211
|
+
except Exception:
|
|
212
|
+
return {}
|
|
213
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def runtime_config() -> Dict[str, Any]:
|
|
217
|
+
global _CONFIG_CACHE
|
|
218
|
+
if _CONFIG_CACHE is not None:
|
|
219
|
+
return _CONFIG_CACHE
|
|
220
|
+
repo_root = repo_root_for_runtime_config()
|
|
221
|
+
legacy_config_dir = repo_root / "config"
|
|
222
|
+
config_dir = repo_root / "configs"
|
|
223
|
+
if not (config_dir / "default.toml").exists():
|
|
224
|
+
if (legacy_config_dir / "default.toml").exists():
|
|
225
|
+
config_dir = legacy_config_dir
|
|
226
|
+
default_cfg = _parse_toml_file(config_dir / "default.toml")
|
|
227
|
+
profile = (
|
|
228
|
+
(os.environ.get("PUSHPALS_PROFILE") or "").strip()
|
|
229
|
+
or str(default_cfg.get("profile") or "").strip()
|
|
230
|
+
or "dev"
|
|
231
|
+
)
|
|
232
|
+
profile_cfg = _parse_toml_file(config_dir / f"{profile}.toml")
|
|
233
|
+
local_cfg = _parse_toml_file(config_dir / "local.toml")
|
|
234
|
+
if (
|
|
235
|
+
not local_cfg
|
|
236
|
+
and config_dir != legacy_config_dir
|
|
237
|
+
and (legacy_config_dir / "local.toml").exists()
|
|
238
|
+
):
|
|
239
|
+
local_cfg = _parse_toml_file(legacy_config_dir / "local.toml")
|
|
240
|
+
_CONFIG_CACHE = _deep_merge(_deep_merge(default_cfg, profile_cfg), local_cfg)
|
|
241
|
+
return _CONFIG_CACHE
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class SettingsResolver:
|
|
245
|
+
"""Thin config interface over env + runtime TOML config.
|
|
246
|
+
|
|
247
|
+
This isolates source precedence (env vs TOML paths) from backend logic so
|
|
248
|
+
call sites consume stable typed accessors.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
env: Optional[Mapping[str, str]] = None,
|
|
255
|
+
config_loader: Optional[Callable[[], Dict[str, Any]]] = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
self._env: Mapping[str, str] = env if env is not None else os.environ
|
|
258
|
+
self._config_loader: Callable[[], Dict[str, Any]] = config_loader or runtime_config
|
|
259
|
+
|
|
260
|
+
def _config_value(self, path: str, default: Any = _MISSING) -> Any:
|
|
261
|
+
node: Any = self._config_loader()
|
|
262
|
+
for part in path.split("."):
|
|
263
|
+
if not isinstance(node, dict) or part not in node:
|
|
264
|
+
return default
|
|
265
|
+
node = node[part]
|
|
266
|
+
return node
|
|
267
|
+
|
|
268
|
+
def _first_env(self, names: Sequence[str]) -> Any:
|
|
269
|
+
for name in names:
|
|
270
|
+
raw = self._env.get(name)
|
|
271
|
+
if raw is None:
|
|
272
|
+
continue
|
|
273
|
+
text = str(raw).strip()
|
|
274
|
+
if text:
|
|
275
|
+
return text
|
|
276
|
+
return _MISSING
|
|
277
|
+
|
|
278
|
+
def _first_config(self, paths: Sequence[str]) -> Any:
|
|
279
|
+
for path in paths:
|
|
280
|
+
value = self._config_value(path, _MISSING)
|
|
281
|
+
if value is _MISSING:
|
|
282
|
+
continue
|
|
283
|
+
if isinstance(value, str):
|
|
284
|
+
trimmed = value.strip()
|
|
285
|
+
if trimmed:
|
|
286
|
+
return trimmed
|
|
287
|
+
continue
|
|
288
|
+
return value
|
|
289
|
+
return _MISSING
|
|
290
|
+
|
|
291
|
+
def get_str(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
env_names: Sequence[str] = (),
|
|
295
|
+
config_paths: Sequence[str] = (),
|
|
296
|
+
default: str = "",
|
|
297
|
+
) -> str:
|
|
298
|
+
env_value = self._first_env(env_names)
|
|
299
|
+
if env_value is not _MISSING:
|
|
300
|
+
return str(env_value)
|
|
301
|
+
cfg_value = self._first_config(config_paths)
|
|
302
|
+
if cfg_value is _MISSING:
|
|
303
|
+
return default
|
|
304
|
+
return str(cfg_value).strip() or default
|
|
305
|
+
|
|
306
|
+
def get_int(
|
|
307
|
+
self,
|
|
308
|
+
*,
|
|
309
|
+
env_names: Sequence[str] = (),
|
|
310
|
+
config_paths: Sequence[str] = (),
|
|
311
|
+
default: int,
|
|
312
|
+
) -> int:
|
|
313
|
+
env_value = self._first_env(env_names)
|
|
314
|
+
if env_value is not _MISSING:
|
|
315
|
+
return to_int(env_value, default)
|
|
316
|
+
cfg_value = self._first_config(config_paths)
|
|
317
|
+
if cfg_value is _MISSING:
|
|
318
|
+
return default
|
|
319
|
+
return to_int(cfg_value, default)
|
|
320
|
+
|
|
321
|
+
def get_float(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
env_names: Sequence[str] = (),
|
|
325
|
+
config_paths: Sequence[str] = (),
|
|
326
|
+
default: float,
|
|
327
|
+
) -> float:
|
|
328
|
+
env_value = self._first_env(env_names)
|
|
329
|
+
if env_value is not _MISSING:
|
|
330
|
+
return to_float(env_value, default)
|
|
331
|
+
cfg_value = self._first_config(config_paths)
|
|
332
|
+
if cfg_value is _MISSING:
|
|
333
|
+
return default
|
|
334
|
+
return to_float(cfg_value, default)
|
|
335
|
+
|
|
336
|
+
def get_bool(
|
|
337
|
+
self,
|
|
338
|
+
*,
|
|
339
|
+
env_names: Sequence[str] = (),
|
|
340
|
+
config_paths: Sequence[str] = (),
|
|
341
|
+
default: bool = False,
|
|
342
|
+
) -> bool:
|
|
343
|
+
env_value = self._first_env(env_names)
|
|
344
|
+
if env_value is not _MISSING:
|
|
345
|
+
lowered = str(env_value).strip().lower()
|
|
346
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
347
|
+
return True
|
|
348
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
349
|
+
return False
|
|
350
|
+
return default
|
|
351
|
+
|
|
352
|
+
cfg_value = self._first_config(config_paths)
|
|
353
|
+
if cfg_value is _MISSING:
|
|
354
|
+
return default
|
|
355
|
+
if isinstance(cfg_value, bool):
|
|
356
|
+
return cfg_value
|
|
357
|
+
if isinstance(cfg_value, (int, float)):
|
|
358
|
+
return bool(cfg_value)
|
|
359
|
+
if isinstance(cfg_value, str):
|
|
360
|
+
lowered = cfg_value.strip().lower()
|
|
361
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
362
|
+
return True
|
|
363
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
364
|
+
return False
|
|
365
|
+
return default
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def build_settings_resolver(
|
|
369
|
+
*,
|
|
370
|
+
env: Optional[Mapping[str, str]] = None,
|
|
371
|
+
config: Optional[Dict[str, Any]] = None,
|
|
372
|
+
) -> SettingsResolver:
|
|
373
|
+
if config is None:
|
|
374
|
+
return SettingsResolver(env=env)
|
|
375
|
+
return SettingsResolver(env=env, config_loader=lambda: config)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def config_get(path: str, default: Any = None) -> Any:
|
|
379
|
+
return build_settings_resolver()._config_value(path, default)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def setting_str(name: str, config_path: str, default: str = "") -> str:
|
|
383
|
+
return build_settings_resolver().get_str(
|
|
384
|
+
env_names=(name,),
|
|
385
|
+
config_paths=(config_path,),
|
|
386
|
+
default=default,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def setting_int(name: str, config_path: str, default: int) -> int:
|
|
391
|
+
return build_settings_resolver().get_int(
|
|
392
|
+
env_names=(name,),
|
|
393
|
+
config_paths=(config_path,),
|
|
394
|
+
default=default,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def setting_float(name: str, config_path: str, default: float) -> float:
|
|
399
|
+
return build_settings_resolver().get_float(
|
|
400
|
+
env_names=(name,),
|
|
401
|
+
config_paths=(config_path,),
|
|
402
|
+
default=default,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def setting_bool(name: str, config_path: str, default: bool = False) -> bool:
|
|
407
|
+
return build_settings_resolver().get_bool(
|
|
408
|
+
env_names=(name,),
|
|
409
|
+
config_paths=(config_path,),
|
|
410
|
+
default=default,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def is_truthy_env(name: str, default: bool = False, config_path: str = "") -> bool:
|
|
415
|
+
if config_path:
|
|
416
|
+
return setting_bool(name, config_path, default)
|
|
417
|
+
return build_settings_resolver().get_bool(env_names=(name,), default=default)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ─── LLM config resolution ──────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
def _normalize_base_url(raw: str) -> str:
|
|
423
|
+
base = raw.strip()
|
|
424
|
+
if not base:
|
|
425
|
+
return ""
|
|
426
|
+
base = base.rstrip("/")
|
|
427
|
+
if base.endswith("/api/chat"):
|
|
428
|
+
base = base[: -len("/api/chat")]
|
|
429
|
+
if base.endswith("/chat/completions"):
|
|
430
|
+
base = base[: -len("/chat/completions")]
|
|
431
|
+
return base
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _model_is_provider_qualified(model: str) -> bool:
|
|
435
|
+
if "/" not in model:
|
|
436
|
+
return False
|
|
437
|
+
provider = model.split("/", 1)[0].strip().lower()
|
|
438
|
+
return provider in KNOWN_LITELLM_PROVIDER_PREFIXES
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def infer_litellm_provider(base_url: str) -> str:
|
|
442
|
+
backend = setting_str("WORKERPALS_LLM_BACKEND", "workerpals.llm.backend", "").lower()
|
|
443
|
+
if backend in {"ollama", "ollama_chat"}:
|
|
444
|
+
return "ollama"
|
|
445
|
+
if backend in {"lmstudio", "openai", "openai_compatible"}:
|
|
446
|
+
return "openai"
|
|
447
|
+
lowered = base_url.lower()
|
|
448
|
+
if "11434" in lowered:
|
|
449
|
+
return "ollama"
|
|
450
|
+
return "openai"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _normalize_litellm_model(model: str, provider: str) -> str:
|
|
454
|
+
normalized = model.strip()
|
|
455
|
+
if not normalized:
|
|
456
|
+
return normalized
|
|
457
|
+
if _model_is_provider_qualified(normalized):
|
|
458
|
+
return normalized
|
|
459
|
+
if not provider:
|
|
460
|
+
return normalized
|
|
461
|
+
return f"{provider}/{normalized}"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _normalize_base_url_for_provider(base_url: str, provider: str) -> str:
|
|
465
|
+
normalized = _normalize_base_url(base_url)
|
|
466
|
+
if not normalized:
|
|
467
|
+
return normalized
|
|
468
|
+
if provider != "openai":
|
|
469
|
+
return normalized
|
|
470
|
+
if re.match(r"^https?://[^/]+$", normalized, flags=re.I):
|
|
471
|
+
return f"{normalized}/v1"
|
|
472
|
+
return normalized
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def running_in_container() -> bool:
|
|
476
|
+
return os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def rewrite_localhost_for_container(base_url: str) -> str:
|
|
480
|
+
import urllib.parse
|
|
481
|
+
|
|
482
|
+
normalized = base_url.strip()
|
|
483
|
+
if not normalized:
|
|
484
|
+
return normalized
|
|
485
|
+
try:
|
|
486
|
+
parsed = urllib.parse.urlparse(normalized)
|
|
487
|
+
except Exception:
|
|
488
|
+
return normalized
|
|
489
|
+
host = (parsed.hostname or "").lower()
|
|
490
|
+
if host not in {"localhost", "127.0.0.1", "::1"}:
|
|
491
|
+
return normalized
|
|
492
|
+
user_info = ""
|
|
493
|
+
if parsed.username:
|
|
494
|
+
user_info = parsed.username
|
|
495
|
+
if parsed.password:
|
|
496
|
+
user_info += f":{parsed.password}"
|
|
497
|
+
user_info += "@"
|
|
498
|
+
netloc = f"{user_info}host.docker.internal"
|
|
499
|
+
if parsed.port:
|
|
500
|
+
netloc += f":{parsed.port}"
|
|
501
|
+
rewritten = urllib.parse.urlunparse(
|
|
502
|
+
(parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)
|
|
503
|
+
)
|
|
504
|
+
return rewritten or normalized
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def looks_local_base_url(base_url: str) -> bool:
|
|
508
|
+
if not base_url:
|
|
509
|
+
return False
|
|
510
|
+
lowered = base_url.lower()
|
|
511
|
+
return "localhost" in lowered or "127.0.0.1" in lowered or "host.docker.internal" in lowered
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def resolve_llm_config(
|
|
515
|
+
default_model: str = "local-model",
|
|
516
|
+
logger: Optional[Logger] = None,
|
|
517
|
+
) -> Tuple[str, str, str]:
|
|
518
|
+
"""Returns (model, api_key, base_url) resolved from config + env."""
|
|
519
|
+
log = logger or Logger("[Executor]")
|
|
520
|
+
raw_model = setting_str("WORKERPALS_LLM_MODEL", "workerpals.llm.model", "")
|
|
521
|
+
api_key = setting_str("WORKERPALS_LLM_API_KEY", "workerpals.llm.api_key", "")
|
|
522
|
+
raw_base_url = setting_str("WORKERPALS_LLM_ENDPOINT", "workerpals.llm.endpoint", "")
|
|
523
|
+
provider = infer_litellm_provider(raw_base_url)
|
|
524
|
+
configured_model = _normalize_litellm_model(raw_model or default_model, provider)
|
|
525
|
+
base_url = _normalize_base_url_for_provider(raw_base_url, provider)
|
|
526
|
+
if running_in_container():
|
|
527
|
+
rewritten = rewrite_localhost_for_container(base_url)
|
|
528
|
+
if rewritten != base_url:
|
|
529
|
+
log.info(f"Rewriting local LLM base URL for container networking: {base_url} -> {rewritten}")
|
|
530
|
+
base_url = rewritten
|
|
531
|
+
if not raw_model.strip():
|
|
532
|
+
log.info(f"No explicit model configured; using default model {default_model}.")
|
|
533
|
+
return configured_model, api_key, base_url
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ─── Git helpers ─────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
def summarize_git_changes(repo: str) -> List[str]:
|
|
539
|
+
try:
|
|
540
|
+
proc = subprocess.run(
|
|
541
|
+
["git", "status", "--porcelain"],
|
|
542
|
+
cwd=repo,
|
|
543
|
+
capture_output=True,
|
|
544
|
+
text=True,
|
|
545
|
+
timeout=20,
|
|
546
|
+
check=False,
|
|
547
|
+
)
|
|
548
|
+
if proc.returncode != 0:
|
|
549
|
+
return []
|
|
550
|
+
paths: List[str] = []
|
|
551
|
+
for raw_line in proc.stdout.splitlines():
|
|
552
|
+
line = str(raw_line or "").rstrip("\r\n")
|
|
553
|
+
if not line.strip():
|
|
554
|
+
continue
|
|
555
|
+
# Porcelain format uses two status columns + space prefix.
|
|
556
|
+
# Do not trim leading whitespace before slicing, otherwise
|
|
557
|
+
# paths like "README.md" become "EADME.md".
|
|
558
|
+
if len(line) < 4:
|
|
559
|
+
continue
|
|
560
|
+
path = line[3:].strip()
|
|
561
|
+
if " -> " in path:
|
|
562
|
+
path = path.split(" -> ", 1)[1]
|
|
563
|
+
if path:
|
|
564
|
+
paths.append(path)
|
|
565
|
+
return paths
|
|
566
|
+
except Exception:
|
|
567
|
+
return []
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def log_git_status(repo: str, logger: Optional[Logger] = None) -> None:
|
|
571
|
+
"""Log ``git status --porcelain`` and ``git diff --stat`` for post-execution visibility."""
|
|
572
|
+
log = logger or Logger("[Executor]")
|
|
573
|
+
try:
|
|
574
|
+
status = subprocess.run(
|
|
575
|
+
["git", "status", "--porcelain"],
|
|
576
|
+
cwd=repo, capture_output=True, text=True, timeout=10, check=False,
|
|
577
|
+
)
|
|
578
|
+
lines = [l for l in (status.stdout or "").splitlines() if l.strip()]
|
|
579
|
+
if lines:
|
|
580
|
+
log.debug("Git status after execution:")
|
|
581
|
+
for line in lines[:30]:
|
|
582
|
+
log.debug(f" {line}")
|
|
583
|
+
else:
|
|
584
|
+
log.debug("Git status: clean (no changes)")
|
|
585
|
+
except Exception as exc:
|
|
586
|
+
log.debug(f"Git status failed: {exc}")
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
diff = subprocess.run(
|
|
590
|
+
["git", "diff", "--stat"],
|
|
591
|
+
cwd=repo, capture_output=True, text=True, timeout=10, check=False,
|
|
592
|
+
)
|
|
593
|
+
diff_lines = [l for l in (diff.stdout or "").splitlines() if l.strip()]
|
|
594
|
+
if diff_lines:
|
|
595
|
+
log.debug("Git diff stat:")
|
|
596
|
+
for line in diff_lines[:20]:
|
|
597
|
+
log.debug(f" {line}")
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def log_agent_messages(messages: list, logger: Optional[Logger] = None, max_chars: int = 200) -> None:
|
|
603
|
+
"""Log a summary of agent message history (works with miniswe's message format).
|
|
604
|
+
|
|
605
|
+
Only emits output when WORKERPALS_DEBUG=1.
|
|
606
|
+
"""
|
|
607
|
+
if not _debug_enabled():
|
|
608
|
+
return
|
|
609
|
+
log = logger or Logger("[Executor]")
|
|
610
|
+
step = 0
|
|
611
|
+
for msg in messages:
|
|
612
|
+
if not isinstance(msg, dict):
|
|
613
|
+
continue
|
|
614
|
+
role = str(msg.get("role") or "").strip()
|
|
615
|
+
if not role:
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
step += 1
|
|
619
|
+
content = str(msg.get("content") or "").strip()
|
|
620
|
+
|
|
621
|
+
# Tool calls (assistant requesting a tool)
|
|
622
|
+
tool_calls = msg.get("tool_calls") or []
|
|
623
|
+
if tool_calls and isinstance(tool_calls, list):
|
|
624
|
+
for tc in tool_calls:
|
|
625
|
+
if isinstance(tc, dict):
|
|
626
|
+
fn = tc.get("function") or {}
|
|
627
|
+
name = fn.get("name") or "unknown"
|
|
628
|
+
args_raw = str(fn.get("arguments") or "")[:80]
|
|
629
|
+
log.debug(f"Step {step} (tool_call): {name}({args_raw})")
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
# Tool response
|
|
633
|
+
if role == "tool":
|
|
634
|
+
name = str(msg.get("name") or msg.get("tool_call_id") or "tool")
|
|
635
|
+
excerpt = to_single_line(content, max_chars)
|
|
636
|
+
log.debug(f"Step {step} (tool_result/{name}): {excerpt}")
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
# Assistant or user text
|
|
640
|
+
excerpt = to_single_line(content, max_chars)
|
|
641
|
+
if excerpt:
|
|
642
|
+
log.debug(f"Step {step} ({role}): {excerpt}")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ─── Payload parsing ────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
@dataclass
|
|
648
|
+
class TaskExecutePayload:
|
|
649
|
+
"""Validated fields extracted from the base64 job payload."""
|
|
650
|
+
kind: str
|
|
651
|
+
params: Dict[str, Any]
|
|
652
|
+
repo: str
|
|
653
|
+
instruction: str
|
|
654
|
+
supplemental_guidance: List[str] = field(default_factory=list)
|
|
655
|
+
payload: Dict[str, Any] = field(default_factory=dict)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _is_non_actionable_planner_guidance(text: str) -> bool:
|
|
659
|
+
lower = str(text or "").strip().lower()
|
|
660
|
+
if not lower:
|
|
661
|
+
return True
|
|
662
|
+
blocked_markers = (
|
|
663
|
+
"no worker instruction needed",
|
|
664
|
+
"no additional instruction needed",
|
|
665
|
+
"purely documentation update",
|
|
666
|
+
"already updated",
|
|
667
|
+
"nothing to do",
|
|
668
|
+
)
|
|
669
|
+
return any(marker in lower for marker in blocked_markers)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def parse_task_execute_payload(
|
|
673
|
+
argv: List[str],
|
|
674
|
+
*,
|
|
675
|
+
accepted_kinds: Tuple[str, ...] = ("task.execute",),
|
|
676
|
+
logger: Optional[Logger] = None,
|
|
677
|
+
) -> TaskExecutePayload:
|
|
678
|
+
"""Decode argv[1], validate required fields, return structured payload.
|
|
679
|
+
|
|
680
|
+
Raises ``SystemExit`` via ``fail()`` on validation errors so callers
|
|
681
|
+
don't need to handle them.
|
|
682
|
+
"""
|
|
683
|
+
log = logger or Logger("[Executor]")
|
|
684
|
+
if len(argv) < 2:
|
|
685
|
+
raise SystemExit(fail("Missing base64 job payload", exit_code=2))
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
payload = decode_payload(argv[1])
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
raise SystemExit(fail(f"Failed to decode job payload: {exc}", exit_code=2))
|
|
691
|
+
|
|
692
|
+
kind = payload.get("kind")
|
|
693
|
+
params = payload.get("params", {})
|
|
694
|
+
repo_raw = payload.get("repo")
|
|
695
|
+
|
|
696
|
+
if not isinstance(kind, str) or not kind:
|
|
697
|
+
raise SystemExit(fail("Invalid payload: missing 'kind'", exit_code=2))
|
|
698
|
+
if not isinstance(params, dict):
|
|
699
|
+
raise SystemExit(fail("Invalid payload: 'params' must be an object", exit_code=2))
|
|
700
|
+
if not isinstance(repo_raw, str) or not repo_raw:
|
|
701
|
+
raise SystemExit(fail("Invalid payload: missing 'repo'", exit_code=2))
|
|
702
|
+
repo, repo_error = resolve_repo_within_assigned_root(repo_raw)
|
|
703
|
+
if repo_error or not repo:
|
|
704
|
+
raise SystemExit(fail(repo_error or "Invalid payload repo path", exit_code=2))
|
|
705
|
+
|
|
706
|
+
if kind not in accepted_kinds:
|
|
707
|
+
kinds_str = ", ".join(accepted_kinds)
|
|
708
|
+
raise SystemExit(
|
|
709
|
+
fail(f"Unsupported job kind '{kind}'. Accepted: {kinds_str}.", exit_code=2)
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
instruction = str(params.get("instruction") or "").strip()
|
|
713
|
+
if not instruction:
|
|
714
|
+
raise SystemExit(fail("task.execute requires 'instruction'", exit_code=2))
|
|
715
|
+
|
|
716
|
+
planner_instruction = str(params.get("plannerWorkerInstruction") or "").strip()
|
|
717
|
+
quality_revision_hint = str(params.get("qualityRevisionHint") or "").strip()
|
|
718
|
+
|
|
719
|
+
supplemental_guidance: List[str] = []
|
|
720
|
+
if planner_instruction and planner_instruction != instruction:
|
|
721
|
+
if _is_non_actionable_planner_guidance(planner_instruction):
|
|
722
|
+
log.info(
|
|
723
|
+
"Planner guidance was provided but ignored due to "
|
|
724
|
+
"non-actionable placeholder content."
|
|
725
|
+
)
|
|
726
|
+
else:
|
|
727
|
+
log.info(
|
|
728
|
+
"Planner guidance was provided, but preserving original "
|
|
729
|
+
"user instruction as canonical task input."
|
|
730
|
+
)
|
|
731
|
+
supplemental_guidance.append(planner_instruction)
|
|
732
|
+
if quality_revision_hint:
|
|
733
|
+
log.info(
|
|
734
|
+
"Quality revision guidance provided for this attempt; "
|
|
735
|
+
"preserving canonical user instruction and applying additive guidance."
|
|
736
|
+
)
|
|
737
|
+
supplemental_guidance.append(quality_revision_hint)
|
|
738
|
+
|
|
739
|
+
return TaskExecutePayload(
|
|
740
|
+
kind=kind,
|
|
741
|
+
params=params,
|
|
742
|
+
repo=repo,
|
|
743
|
+
instruction=instruction,
|
|
744
|
+
supplemental_guidance=supplemental_guidance,
|
|
745
|
+
payload=payload,
|
|
746
|
+
)
|