@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.
Files changed (106) hide show
  1. package/dist/pushpals-cli.js +277 -12
  2. package/package.json +1 -1
  3. package/runtime/sandbox/apps/workerpals/.python-version +1 -0
  4. package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
  5. package/runtime/sandbox/apps/workerpals/package.json +25 -0
  6. package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
  7. package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +111 -0
  8. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
  9. package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
  10. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
  11. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
  12. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
  13. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
  14. package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
  15. package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
  17. package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
  18. package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
  19. package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
  20. package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
  21. package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
  22. package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
  23. package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
  24. package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
  25. package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
  26. package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
  27. package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
  28. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
  29. package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
  30. package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
  31. package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
  32. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
  33. package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
  34. package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
  35. package/runtime/sandbox/bun.lock +2591 -0
  36. package/runtime/sandbox/configs/backend.toml +79 -0
  37. package/runtime/sandbox/configs/default.toml +260 -0
  38. package/runtime/sandbox/configs/dev.toml +2 -0
  39. package/runtime/sandbox/configs/local.example.toml +129 -0
  40. package/runtime/sandbox/package.json +65 -0
  41. package/runtime/sandbox/packages/protocol/README.md +168 -0
  42. package/runtime/sandbox/packages/protocol/package.json +37 -0
  43. package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
  44. package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
  45. package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
  46. package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
  47. package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
  48. package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
  49. package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
  50. package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
  51. package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
  52. package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
  53. package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
  54. package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
  55. package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
  56. package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
  57. package/runtime/sandbox/packages/shared/package.json +19 -0
  58. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
  59. package/runtime/sandbox/packages/shared/src/client_preflight.ts +297 -0
  60. package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
  61. package/runtime/sandbox/packages/shared/src/config.ts +2201 -0
  62. package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
  63. package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
  64. package/runtime/sandbox/packages/shared/src/index.ts +100 -0
  65. package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
  66. package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +329 -0
  67. package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
  68. package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
  69. package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
  70. package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
  71. package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
  72. package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
  73. package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
  74. package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
  75. package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
  76. package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
  77. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
  78. package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
  79. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
  80. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
  81. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
  82. package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
  83. package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
  84. package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
  85. package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
  86. package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
  87. package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
  88. package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
  89. package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
  90. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
  91. package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
  92. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
  93. package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
  94. package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
  95. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
  96. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
  97. package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
  98. package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
  99. package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
  100. package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
  101. package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
  102. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
  103. package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
  104. package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
  105. package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
  106. package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env python3
2
+ """PushPals OpenHands executor wrapper.
3
+
4
+ Minimal wrapper contract:
5
+ - decode payload via executor_base
6
+ - run OpenHands SDK task.execute flow
7
+ - emit one structured result line
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ _SHARED_DIR = Path(__file__).resolve().parents[1] / "shared"
20
+ if str(_SHARED_DIR) not in sys.path:
21
+ sys.path.insert(0, str(_SHARED_DIR))
22
+
23
+ from executor_base import (
24
+ Logger,
25
+ emit,
26
+ is_no_tool_calls_error,
27
+ is_truthy_env,
28
+ log_git_status,
29
+ looks_local_base_url,
30
+ parse_task_execute_payload,
31
+ repo_root_for_runtime_config,
32
+ resolve_llm_config,
33
+ setting_int,
34
+ setting_str,
35
+ summarize_git_changes,
36
+ to_int,
37
+ to_single_line,
38
+ )
39
+
40
+ LOG_PREFIX = "[OpenHandsExecutor]"
41
+ log = Logger(LOG_PREFIX)
42
+ DEFAULT_OPENHANDS_MODEL = "local-model"
43
+ PROMPT_TOKEN_REGEX = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
44
+ GLOB_META_REGEX = re.compile(r"[*?\[\]{}()!]")
45
+ _PROMPT_TEMPLATE_CACHE: Dict[str, str] = {}
46
+
47
+
48
+ def _safe_session_component(value: Any, fallback: str = "unknown") -> str:
49
+ text = str(value or "").strip().lower()
50
+ if not text:
51
+ text = fallback
52
+ text = re.sub(r"[^a-z0-9._:-]+", "-", text)
53
+ text = re.sub(r"-{2,}", "-", text).strip("-")
54
+ if not text:
55
+ text = fallback
56
+ return text[:64]
57
+
58
+
59
+ def _stable_llm_session_user(payload: Optional[Dict[str, Any]]) -> str:
60
+ override = setting_str("WORKERPALS_LLM_SESSION_ID", "workerpals.llm.session_id", "")
61
+ if override:
62
+ return _safe_session_component(override, "pushpals-worker")
63
+
64
+ session_id = _safe_session_component(setting_str("PUSHPALS_SESSION_ID", "session_id", ""), "session")
65
+ worker_id = _safe_session_component((payload or {}).get("workerId"), "worker")
66
+ task_id = _safe_session_component((payload or {}).get("taskId"), "task")
67
+ return f"pushpals-{session_id}-{worker_id}-{task_id}"
68
+
69
+
70
+ def _session_hint_headers(session_user: str) -> Dict[str, str]:
71
+ if not session_user:
72
+ return {}
73
+ return {
74
+ "X-PushPals-Session-Id": session_user,
75
+ "X-Session-Id": session_user,
76
+ "X-Conversation-Id": session_user,
77
+ }
78
+
79
+
80
+ def _repo_root_for_prompt_loading() -> Path:
81
+ return repo_root_for_runtime_config()
82
+
83
+
84
+ def _resolve_prompt_file(relative_path: str) -> Path:
85
+ return _repo_root_for_prompt_loading() / "prompts" / relative_path
86
+
87
+
88
+ def _load_prompt_template(relative_path: str, replacements: Optional[Dict[str, str]] = None) -> str:
89
+ prompt_path = _resolve_prompt_file(relative_path)
90
+ prompt_key = str(prompt_path)
91
+
92
+ template = _PROMPT_TEMPLATE_CACHE.get(prompt_key)
93
+ if template is None:
94
+ if not prompt_path.exists():
95
+ raise FileNotFoundError(f"Prompt template not found: {prompt_path}")
96
+ template = prompt_path.read_text(encoding="utf-8")
97
+ _PROMPT_TEMPLATE_CACHE[prompt_key] = template
98
+
99
+ if not replacements:
100
+ return template
101
+
102
+ def _replace(match: re.Match[str]) -> str:
103
+ key = match.group(1)
104
+ if key not in replacements:
105
+ raise KeyError(f"Missing prompt replacement '{{{{{key}}}}}' for {prompt_path}")
106
+ return replacements[key]
107
+
108
+ return PROMPT_TOKEN_REGEX.sub(_replace, template)
109
+
110
+
111
+ def _resolve_agent_prompt_overrides(base_url: str) -> Dict[str, Any]:
112
+ profile = setting_str(
113
+ "WORKERPALS_OPENHANDS_PROMPT_PROFILE",
114
+ "workerpals.openhands.prompt_profile",
115
+ "minimal" if looks_local_base_url(base_url) else "default",
116
+ ).lower()
117
+ if profile not in {"minimal", "compact", "small"}:
118
+ return {}
119
+
120
+ overrides: Dict[str, Any] = {}
121
+ system_prompt = _resolve_prompt_file("workerpals/openhands_minimal_system_prompt.j2")
122
+ security_prompt = _resolve_prompt_file("workerpals/openhands_minimal_security_policy.j2")
123
+ if system_prompt.exists():
124
+ overrides["system_prompt_filename"] = str(system_prompt)
125
+ if security_prompt.exists():
126
+ overrides["security_policy_filename"] = str(security_prompt)
127
+ return overrides
128
+
129
+
130
+ def _json_object_from_env(name: str) -> Tuple[Optional[Dict[str, Any]], str]:
131
+ raw = (os.environ.get(name) or "").strip()
132
+ if not raw:
133
+ return None, ""
134
+ try:
135
+ parsed = json.loads(raw)
136
+ except Exception as exc:
137
+ return None, f"{name}: invalid JSON ({exc})"
138
+ if not isinstance(parsed, dict):
139
+ return None, f"{name}: expected a JSON object"
140
+ return parsed, ""
141
+
142
+
143
+ def _resolve_mcp_config() -> Tuple[Optional[Dict[str, Any]], List[str]]:
144
+ notes: List[str] = []
145
+ config: Optional[Dict[str, Any]] = None
146
+
147
+ raw = setting_str("WORKERPALS_OPENHANDS_MCP_CONFIG_JSON", "workerpals.openhands.mcp_config_json", "")
148
+ if raw:
149
+ try:
150
+ parsed = json.loads(raw)
151
+ if isinstance(parsed, dict):
152
+ config = parsed
153
+ else:
154
+ notes.append(f"{LOG_PREFIX} Ignoring MCP config: expected JSON object.")
155
+ except Exception as exc:
156
+ notes.append(f"{LOG_PREFIX} Ignoring MCP config JSON: {exc}")
157
+
158
+ if not is_truthy_env("WORKERPALS_OPENHANDS_ENABLE_WEB_MCP", False, "workerpals.openhands.enable_web_mcp"):
159
+ return config, notes
160
+
161
+ web_url = setting_str("WORKERPALS_OPENHANDS_WEB_MCP_URL", "workerpals.openhands.web_mcp_url", "")
162
+ if not web_url:
163
+ notes.append(f"{LOG_PREFIX} Web MCP enabled but URL is empty; skipping.")
164
+ return config, notes
165
+
166
+ server_name = setting_str(
167
+ "WORKERPALS_OPENHANDS_WEB_MCP_NAME",
168
+ "workerpals.openhands.web_mcp_name",
169
+ "web-search",
170
+ )
171
+ transport = setting_str(
172
+ "WORKERPALS_OPENHANDS_WEB_MCP_TRANSPORT",
173
+ "workerpals.openhands.web_mcp_transport",
174
+ "streamable-http",
175
+ ).lower().replace("streamable_http", "streamable-http")
176
+ if transport not in {"http", "streamable-http", "sse"}:
177
+ transport = "streamable-http"
178
+
179
+ headers, headers_error = _json_object_from_env("WORKERPALS_OPENHANDS_WEB_MCP_HEADERS_JSON")
180
+ if headers_error:
181
+ notes.append(f"{LOG_PREFIX} Ignoring MCP headers JSON: {headers_error}")
182
+ headers = None
183
+
184
+ auth_token = (os.environ.get("WORKERPALS_OPENHANDS_WEB_MCP_AUTH_TOKEN") or "").strip()
185
+ timeout_sec = setting_int(
186
+ "WORKERPALS_OPENHANDS_WEB_MCP_TIMEOUT_SEC",
187
+ "workerpals.openhands.web_mcp_timeout_sec",
188
+ 0,
189
+ )
190
+
191
+ server_config: Dict[str, Any] = {"url": web_url, "transport": transport}
192
+ if headers:
193
+ server_config["headers"] = {
194
+ str(k): str(v)
195
+ for k, v in headers.items()
196
+ if isinstance(k, str) and isinstance(v, (str, int, float, bool))
197
+ }
198
+ if auth_token:
199
+ server_config["auth"] = auth_token
200
+ if timeout_sec > 0:
201
+ server_config["timeout"] = timeout_sec
202
+
203
+ if config is None:
204
+ config = {"mcpServers": {}}
205
+ if not isinstance(config.get("mcpServers"), dict):
206
+ config["mcpServers"] = {}
207
+ config["mcpServers"][server_name] = server_config
208
+ notes.append(f"{LOG_PREFIX} Web MCP enabled: {server_name} -> {web_url} ({transport})")
209
+ return config, notes
210
+
211
+
212
+ def _browser_tool_enabled() -> bool:
213
+ return is_truthy_env(
214
+ "WORKERPALS_OPENHANDS_ENABLE_BROWSER_TOOL",
215
+ False,
216
+ "workerpals.openhands.enable_browser_tool",
217
+ )
218
+
219
+
220
+ def _build_user_message(instruction: str, timeout_ms: int) -> str:
221
+ timeout_minutes = max(1, round(timeout_ms / 60000))
222
+ timeout_note = _load_prompt_template(
223
+ "workerpals/openhands_timeout_note.md",
224
+ {"timeout_minutes": str(timeout_minutes)},
225
+ ).strip()
226
+
227
+ mode = setting_str(
228
+ "WORKERPALS_OPENHANDS_TASK_PROMPT_MODE",
229
+ "workerpals.openhands.task_prompt_mode",
230
+ "none",
231
+ ).lower()
232
+ if mode in {"none", "off", "instruction_only", "instruction-only", "minimal"}:
233
+ return f"{instruction}\n\n{timeout_note}"
234
+
235
+ try:
236
+ system_prompt = _load_prompt_template("workerpals/openhands_task_execute_system_prompt.md").strip()
237
+ except Exception:
238
+ system_prompt = _load_prompt_template(
239
+ "workerpals/openhands_task_execute_fallback_system_prompt.md"
240
+ ).strip()
241
+ return _load_prompt_template(
242
+ "workerpals/openhands_task_user_prompt.md",
243
+ {
244
+ "system_prompt": system_prompt,
245
+ "instruction": instruction,
246
+ "timeout_note": timeout_note,
247
+ },
248
+ ).strip()
249
+
250
+
251
+ def _build_strict_tool_use_message() -> str:
252
+ return _load_prompt_template("workerpals/openhands_strict_tool_use_message.md").strip()
253
+
254
+
255
+ def _normalize_repo_relative_path(value: Any) -> Optional[str]:
256
+ if not isinstance(value, str):
257
+ return None
258
+ path = value.strip().replace("\\", "/")
259
+ if not path:
260
+ return None
261
+ if path == "/repo" or path == "/workspace":
262
+ return "."
263
+ if path.startswith("/repo/"):
264
+ path = path[len("/repo/") :]
265
+ elif path.startswith("/workspace/"):
266
+ path = path[len("/workspace/") :]
267
+ elif path.startswith("/"):
268
+ return None
269
+ if re.match(r"^[A-Za-z]:[\\/]", path):
270
+ return None
271
+ while path.startswith("./"):
272
+ path = path[2:]
273
+ path = re.sub(r"/+", "/", path).strip("/")
274
+ if not path:
275
+ return None
276
+ segments = path.split("/")
277
+ out: List[str] = []
278
+ for segment in segments:
279
+ segment = segment.strip()
280
+ if not segment or segment == ".":
281
+ continue
282
+ if segment == "..":
283
+ return None
284
+ out.append(segment)
285
+ if not out:
286
+ return None
287
+ return "/".join(out)
288
+
289
+
290
+ def _add_literal_path(values: List[str], seen: set[str], raw: Any) -> None:
291
+ normalized = _normalize_repo_relative_path(raw)
292
+ if not normalized or normalized == ".":
293
+ return
294
+ if GLOB_META_REGEX.search(normalized):
295
+ return
296
+ key = normalized.lower()
297
+ if key in seen:
298
+ return
299
+ seen.add(key)
300
+ values.append(normalized)
301
+
302
+
303
+ def _extract_target_paths(payload: Optional[Dict[str, Any]]) -> List[str]:
304
+ out: List[str] = []
305
+ seen: set[str] = set()
306
+ if not isinstance(payload, dict):
307
+ return out
308
+ params = payload.get("params")
309
+ if not isinstance(params, dict):
310
+ return out
311
+
312
+ _add_literal_path(out, seen, params.get("targetPath"))
313
+ _add_literal_path(out, seen, params.get("path"))
314
+ paths = params.get("paths")
315
+ if isinstance(paths, list):
316
+ for entry in paths:
317
+ _add_literal_path(out, seen, entry)
318
+ if len(out) >= 8:
319
+ return out
320
+
321
+ planning = params.get("planning")
322
+ if not isinstance(planning, dict):
323
+ return out
324
+ target_paths = planning.get("targetPaths")
325
+ if isinstance(target_paths, list):
326
+ for entry in target_paths:
327
+ _add_literal_path(out, seen, entry)
328
+ if len(out) >= 8:
329
+ return out
330
+ scope = planning.get("scope")
331
+ if isinstance(scope, dict):
332
+ write_globs = scope.get("writeGlobs")
333
+ if isinstance(write_globs, list):
334
+ for entry in write_globs:
335
+ _add_literal_path(out, seen, entry)
336
+ if len(out) >= 8:
337
+ return out
338
+ return out
339
+
340
+
341
+ def _build_path_handling_message(target_paths: List[str], repo: str) -> str:
342
+ if not target_paths:
343
+ return ""
344
+ rel_paths = target_paths[:8]
345
+ listed_rel = "\n".join(f"- {path}" for path in rel_paths)
346
+ repo_root = str(Path(repo).resolve()).replace("\\", "/").rstrip("/")
347
+ abs_paths = [f"{repo_root}/{path}" for path in rel_paths]
348
+ listed_abs = "\n".join(f"- {path}" for path in abs_paths)
349
+ return (
350
+ "Path handling requirements:\n"
351
+ "- The current working directory is the repository root.\n"
352
+ "- Prefer the repo-relative paths for shell commands.\n"
353
+ "- If FileEditor rejects a repo-relative path, retry with the matching absolute path.\n"
354
+ "- Do not run broad filesystem scans when concrete target paths are listed.\n"
355
+ "Concrete target paths (repo-relative):\n"
356
+ f"{listed_rel}\n"
357
+ "Concrete target paths (absolute):\n"
358
+ f"{listed_abs}"
359
+ )
360
+
361
+
362
+ def _run_openhands_task(
363
+ repo: str,
364
+ instruction: str,
365
+ payload: Optional[Dict[str, Any]] = None,
366
+ supplemental_guidance: Optional[List[str]] = None,
367
+ ) -> Dict[str, Any]:
368
+ try:
369
+ from openhands.sdk import Agent, Conversation, LLM, Tool
370
+ from openhands.tools.file_editor import FileEditorTool
371
+ from openhands.tools.terminal import TerminalTool
372
+ except Exception as exc:
373
+ return {
374
+ "ok": False,
375
+ "summary": "OpenHands SDK is not installed. Install with: pip install openhands-ai",
376
+ "stderr": str(exc),
377
+ "exitCode": 3,
378
+ }
379
+
380
+ model, api_key, base_url = resolve_llm_config(
381
+ default_model=DEFAULT_OPENHANDS_MODEL,
382
+ logger=log,
383
+ )
384
+ if not model:
385
+ return {
386
+ "ok": False,
387
+ "summary": "task.execute requires an LLM model. Set WORKERPALS_LLM_MODEL.",
388
+ "stderr": "",
389
+ "exitCode": 2,
390
+ }
391
+
392
+ if not api_key:
393
+ if looks_local_base_url(base_url):
394
+ api_key = "local"
395
+ else:
396
+ return {
397
+ "ok": False,
398
+ "summary": "task.execute requires an API key. Set WORKERPALS_LLM_API_KEY.",
399
+ "stderr": "",
400
+ "exitCode": 2,
401
+ }
402
+
403
+ llm_kwargs: Dict[str, Any] = {
404
+ "model": model,
405
+ "api_key": api_key,
406
+ }
407
+ if base_url:
408
+ llm_kwargs["base_url"] = base_url
409
+
410
+ session_user = _stable_llm_session_user(payload)
411
+ if session_user:
412
+ llm_kwargs["litellm_extra_body"] = {
413
+ "user": session_user,
414
+ "session_id": session_user,
415
+ "conversation_id": session_user,
416
+ }
417
+ llm_kwargs["extra_headers"] = _session_hint_headers(session_user)
418
+
419
+ max_steps = max(
420
+ 1,
421
+ setting_int(
422
+ "WORKERPALS_OPENHANDS_AGENT_MAX_STEPS",
423
+ "workerpals.openhands.agent_max_steps",
424
+ 30,
425
+ ),
426
+ )
427
+ timeout_ms = to_int((payload or {}).get("timeoutMs"), 0)
428
+ if timeout_ms <= 0:
429
+ timeout_ms = max(
430
+ 10_000,
431
+ setting_int("WORKERPALS_OPENHANDS_TIMEOUT_MS", "workerpals.openhands_timeout_ms", 300_000),
432
+ )
433
+
434
+ mcp_config, mcp_notes = _resolve_mcp_config()
435
+ for note in mcp_notes:
436
+ log.info(note.removeprefix(f"{LOG_PREFIX} ").strip())
437
+
438
+ tools = [Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)]
439
+ if _browser_tool_enabled():
440
+ try:
441
+ from openhands.tools.browser_use import BrowserToolSet
442
+
443
+ tools.append(Tool(name=BrowserToolSet.name))
444
+ log.info("BrowserToolSet enabled.")
445
+ except Exception as exc:
446
+ log.info(f"Browser tooling unavailable: {to_single_line(exc, 300)}")
447
+
448
+ try:
449
+ llm = LLM(**llm_kwargs)
450
+
451
+ agent_kwargs: Dict[str, Any] = {"llm": llm, "tools": tools}
452
+ agent_kwargs.update(_resolve_agent_prompt_overrides(base_url))
453
+ if mcp_config:
454
+ agent_kwargs["mcp_config"] = mcp_config
455
+
456
+ try:
457
+ agent = Agent(**agent_kwargs)
458
+ except TypeError:
459
+ # Older SDKs may not support prompt overrides/mcp_config.
460
+ agent = Agent(llm=llm, tools=tools)
461
+
462
+ conversation = Conversation(agent=agent, workspace=repo)
463
+ log.debug(f"Instruction: {to_single_line(instruction, 300)}")
464
+ conversation.send_message(_build_user_message(instruction, timeout_ms))
465
+ path_handling = _build_path_handling_message(_extract_target_paths(payload), repo)
466
+ if path_handling:
467
+ conversation.send_message(path_handling)
468
+ if supplemental_guidance:
469
+ for guidance in supplemental_guidance:
470
+ trimmed = str(guidance or "").strip()
471
+ if trimmed:
472
+ conversation.send_message(
473
+ _load_prompt_template(
474
+ "workerpals/openhands_supplemental_guidance_message.md",
475
+ {"guidance": trimmed},
476
+ ).strip()
477
+ )
478
+
479
+ try:
480
+ conversation.run(max_steps=max_steps)
481
+ except TypeError:
482
+ conversation.run()
483
+ except Exception as run_exc:
484
+ if is_no_tool_calls_error(run_exc):
485
+ # One strict nudge before surfacing failure.
486
+ conversation.send_message(_build_strict_tool_use_message())
487
+ try:
488
+ conversation.run(max_steps=max_steps)
489
+ except TypeError:
490
+ conversation.run()
491
+ except Exception:
492
+ raise run_exc
493
+ else:
494
+ raise
495
+
496
+ log.debug("Agent execution completed.")
497
+ # Log conversation events if the SDK exposes them
498
+ try:
499
+ events = getattr(conversation, "events", None) or getattr(conversation, "get_events", lambda: None)()
500
+ if events:
501
+ log.debug(f"Conversation events ({len(events)}):")
502
+ for i, event in enumerate(events[:30], 1):
503
+ if isinstance(event, dict):
504
+ action = event.get("action") or event.get("type") or "unknown"
505
+ args = event.get("args") or {}
506
+ path = args.get("path") or args.get("command") or ""
507
+ log.debug(f" Event {i}: {action} {to_single_line(str(path), 120)}")
508
+ else:
509
+ log.debug(f" Event {i}: {to_single_line(str(event), 150)}")
510
+ except Exception:
511
+ pass
512
+ log_git_status(repo, log)
513
+
514
+ except Exception as exc:
515
+ if is_no_tool_calls_error(exc):
516
+ return {
517
+ "ok": False,
518
+ "summary": "OpenHands could not execute: model did not emit tool calls/actions",
519
+ "stderr": (
520
+ "Agentic execution requires a tool-capable model/runtime. "
521
+ "The agent did not produce tool calls/actions.\n"
522
+ f"Error: {to_single_line(exc, 700)}"
523
+ ),
524
+ "exitCode": 3,
525
+ }
526
+ return {
527
+ "ok": False,
528
+ "summary": "OpenHands task execution failed",
529
+ "stderr": str(exc),
530
+ "exitCode": 1,
531
+ }
532
+
533
+ changed_paths = summarize_git_changes(repo)
534
+ if changed_paths:
535
+ listed = "\n".join(f"- {path}" for path in changed_paths[:40])
536
+ if len(changed_paths) > 40:
537
+ listed += "\n- ..."
538
+ return {
539
+ "ok": True,
540
+ "summary": f"Executed task and modified {len(changed_paths)} file(s)",
541
+ "stdout": f"Changed files:\n{listed}",
542
+ "stderr": "",
543
+ "exitCode": 0,
544
+ }
545
+
546
+ return {
547
+ "ok": True,
548
+ "summary": "Executed task via OpenHands (no file changes detected)",
549
+ "stdout": "No modified files were detected after execution.",
550
+ "stderr": "",
551
+ "exitCode": 0,
552
+ }
553
+
554
+
555
+ def main() -> int:
556
+ task = parse_task_execute_payload(sys.argv, logger=log)
557
+ result = _run_openhands_task(task.repo, task.instruction, task.payload, task.supplemental_guidance)
558
+ emit(result)
559
+ return 0 if bool(result.get("ok")) else to_int(result.get("exitCode"), 1)
560
+
561
+
562
+ if __name__ == "__main__":
563
+ raise SystemExit(main())