@pushpalsdev/cli 1.0.18 → 1.0.20

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.
Files changed (108) hide show
  1. package/dist/pushpals-cli.js +291 -44
  2. package/package.json +1 -1
  3. package/runtime/configs/backend.toml +1 -1
  4. package/runtime/configs/default.toml +1 -1
  5. package/runtime/sandbox/apps/workerpals/.python-version +1 -0
  6. package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
  7. package/runtime/sandbox/apps/workerpals/package.json +25 -0
  8. package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
  9. package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +119 -0
  10. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
  11. package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
  12. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
  13. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
  14. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
  15. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
  17. package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
  18. package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
  19. package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
  20. package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
  21. package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
  22. package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
  23. package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
  24. package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
  25. package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
  26. package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
  27. package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
  28. package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
  29. package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
  30. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
  31. package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
  32. package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
  33. package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
  34. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
  35. package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
  36. package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
  37. package/runtime/sandbox/bun.lock +2591 -0
  38. package/runtime/sandbox/configs/backend.toml +79 -0
  39. package/runtime/sandbox/configs/default.toml +260 -0
  40. package/runtime/sandbox/configs/dev.toml +2 -0
  41. package/runtime/sandbox/configs/local.example.toml +129 -0
  42. package/runtime/sandbox/package.json +65 -0
  43. package/runtime/sandbox/packages/protocol/README.md +168 -0
  44. package/runtime/sandbox/packages/protocol/package.json +37 -0
  45. package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
  46. package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
  47. package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
  48. package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
  49. package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
  50. package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
  51. package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
  52. package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
  53. package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
  54. package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
  55. package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
  56. package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
  57. package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
  58. package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
  59. package/runtime/sandbox/packages/shared/package.json +19 -0
  60. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
  61. package/runtime/sandbox/packages/shared/src/client_preflight.ts +286 -0
  62. package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
  63. package/runtime/sandbox/packages/shared/src/config.ts +2180 -0
  64. package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
  65. package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
  66. package/runtime/sandbox/packages/shared/src/index.ts +101 -0
  67. package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
  68. package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +314 -0
  69. package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
  70. package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
  71. package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
  72. package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
  73. package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
  74. package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
  75. package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
  76. package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
  77. package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
  78. package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
  79. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
  80. package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
  81. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
  82. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
  83. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
  84. package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
  85. package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
  86. package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
  87. package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
  88. package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
  89. package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
  90. package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
  91. package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
  92. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
  93. package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
  94. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
  95. package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
  96. package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
  97. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
  98. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
  99. package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
  100. package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
  101. package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
  102. package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
  103. package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
  104. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
  105. package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
  106. package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
  107. package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
  108. 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
+ )