@openhands/extensions 0.25.0 → 0.27.0

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 (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +67 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -0,0 +1,338 @@
1
+ """End-to-end tests for warm_runtime_configs against an in-process HTTP server.
2
+
3
+ Uses only the standard library. Boots a threading HTTP server, points
4
+ RUNTIME_API_URL at it, and runs each CLI subcommand as if it were a real
5
+ runtime-api install.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import binascii
11
+ import contextlib
12
+ import hashlib
13
+ import importlib.util
14
+ import io
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import unittest
20
+ from http.server import BaseHTTPRequestHandler, HTTPServer
21
+ from pathlib import Path
22
+
23
+
24
+ PLUGIN_DIR = Path(__file__).resolve().parents[1]
25
+ SCRIPT_PATH = PLUGIN_DIR / "scripts" / "warm_runtime_configs.py"
26
+
27
+ spec = importlib.util.spec_from_file_location("wrc", SCRIPT_PATH)
28
+ assert spec and spec.loader
29
+ wrc = importlib.util.module_from_spec(spec)
30
+ spec.loader.exec_module(wrc)
31
+
32
+
33
+ ADMIN_PASSWORD = "hunter2"
34
+ API_KEY = "test-api-key"
35
+ SALT = "salt-value"
36
+ CHALLENGE = "challenge-value"
37
+ ITERATIONS = 1000
38
+
39
+ EXPECTED_HASH = binascii.hexlify(
40
+ hashlib.pbkdf2_hmac(
41
+ "sha256",
42
+ ADMIN_PASSWORD.encode(),
43
+ (SALT + CHALLENGE).encode(),
44
+ ITERATIONS,
45
+ dklen=32,
46
+ )
47
+ ).decode()
48
+
49
+ ADMIN_JWT = "test-jwt"
50
+
51
+
52
+ class FakeRuntimeAPI(BaseHTTPRequestHandler):
53
+ """In-process stand-in for the runtime-api admin endpoints."""
54
+
55
+ configs: dict[str, dict] = {}
56
+ calls: list[tuple[str, str, dict, dict]] = []
57
+ initial_configs: list[dict] = []
58
+
59
+ def log_message(self, format, *args): # silence stdout noise
60
+ pass
61
+
62
+ def _read_body(self) -> dict:
63
+ length = int(self.headers.get("Content-Length") or 0)
64
+ raw = self.rfile.read(length).decode() if length else ""
65
+ return json.loads(raw) if raw else {}
66
+
67
+ def _respond(self, status: int, payload: dict) -> None:
68
+ body = json.dumps(payload).encode()
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(body)))
72
+ self.end_headers()
73
+ self.wfile.write(body)
74
+
75
+ def do_GET(self):
76
+ if self.path == "/api/admin/challenge":
77
+ self._respond(200, {"salt": SALT, "challenge": CHALLENGE, "iterations": ITERATIONS})
78
+ return
79
+ if self.path == "/api/admin/api-keys":
80
+ if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
81
+ self._respond(401, {"error": "bad token"})
82
+ return
83
+ self.calls.append(("GET", self.path, dict(self.headers), {}))
84
+ # Real runtime-api returns a top-level JSON array of ApiKeyResponse.
85
+ self._respond_raw(200, json.dumps([
86
+ {"id": 1, "name": "default", "key_value": API_KEY},
87
+ {"id": 2, "name": "other", "key_value": "other-key"},
88
+ ]).encode())
89
+ return
90
+ if self.path == "/api/warm-runtime-configs":
91
+ if self.headers.get("X-API-Key") != API_KEY:
92
+ self._respond(401, {"error": "bad api key"})
93
+ return
94
+ merged = list(self.initial_configs) + [
95
+ dict(name=n, source="db", **c) for n, c in self.configs.items()
96
+ ]
97
+ self._respond(200, {"configs": merged})
98
+ return
99
+ self._respond(404, {"error": "not found"})
100
+
101
+ def _respond_raw(self, status: int, body: bytes) -> None:
102
+ self.send_response(status)
103
+ self.send_header("Content-Type", "application/json")
104
+ self.send_header("Content-Length", str(len(body)))
105
+ self.end_headers()
106
+ self.wfile.write(body)
107
+
108
+ def do_POST(self):
109
+ if self.path == "/api/admin/login":
110
+ body = self._read_body()
111
+ if body.get("challenge") == CHALLENGE and body.get("hash") == EXPECTED_HASH:
112
+ self._respond(200, {"token": ADMIN_JWT})
113
+ else:
114
+ self._respond(401, {"error": "bad password"})
115
+ return
116
+ self._respond(404, {"error": "not found"})
117
+
118
+ def do_PUT(self):
119
+ prefix = "/api/admin/warm-runtime-configs/"
120
+ if not self.path.startswith(prefix):
121
+ self._respond(404, {"error": "not found"})
122
+ return
123
+ if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
124
+ self._respond(401, {"error": "bad token"})
125
+ return
126
+ name = self.path[len(prefix):]
127
+ body = self._read_body()
128
+ self.configs[name] = body
129
+ self.calls.append(("PUT", name, dict(self.headers), body))
130
+ self._respond(200, {"name": name, **body})
131
+
132
+ def do_DELETE(self):
133
+ prefix = "/api/admin/warm-runtime-configs/"
134
+ if not self.path.startswith(prefix):
135
+ self._respond(404, {"error": "not found"})
136
+ return
137
+ if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
138
+ self._respond(401, {"error": "bad token"})
139
+ return
140
+ name = self.path[len(prefix):]
141
+ removed = self.configs.pop(name, None)
142
+ self.calls.append(("DELETE", name, dict(self.headers), {}))
143
+ if removed is None:
144
+ self._respond(404, {"error": f"no such config {name}"})
145
+ else:
146
+ self._respond(200, {"message": f"deleted {name}"})
147
+
148
+
149
+ class CliIntegrationTests(unittest.TestCase):
150
+ server: HTTPServer
151
+ thread: threading.Thread
152
+
153
+ @classmethod
154
+ def setUpClass(cls) -> None:
155
+ cls.server = HTTPServer(("127.0.0.1", 0), FakeRuntimeAPI)
156
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
157
+ cls.thread.start()
158
+ host, port = cls.server.server_address
159
+ os.environ["RUNTIME_API_URL"] = f"http://{host}:{port}"
160
+ os.environ["API_KEY"] = API_KEY
161
+ os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
162
+
163
+ @classmethod
164
+ def tearDownClass(cls) -> None:
165
+ cls.server.shutdown()
166
+ cls.thread.join(timeout=2)
167
+
168
+ def setUp(self) -> None:
169
+ FakeRuntimeAPI.configs.clear()
170
+ FakeRuntimeAPI.calls.clear()
171
+ FakeRuntimeAPI.initial_configs = [
172
+ {
173
+ "name": "v1_current",
174
+ "source": "file",
175
+ "image": "ghcr.io/openhands/agent-server:1.46.0-python",
176
+ "count": 1,
177
+ "command": ["/usr/local/bin/openhands-agent-server", "--port", "60000"],
178
+ "working_dir": "/workspace/project",
179
+ "environment": {"BASE": "1"},
180
+ }
181
+ ]
182
+ os.environ.pop("ADMIN_TOKEN", None)
183
+
184
+ @contextlib.contextmanager
185
+ def _capture_stdout(self):
186
+ buf = io.StringIO()
187
+ old = sys.stdout
188
+ sys.stdout = buf
189
+ try:
190
+ yield buf
191
+ finally:
192
+ sys.stdout = old
193
+
194
+ def test_list_returns_configs(self) -> None:
195
+ with self._capture_stdout() as out:
196
+ wrc.main(["list"])
197
+ configs = json.loads(out.getvalue())
198
+ self.assertEqual([c["name"] for c in configs], ["v1_current"])
199
+
200
+ def test_template_strips_identity_and_applies_overrides(self) -> None:
201
+ with self._capture_stdout() as out:
202
+ wrc.main(
203
+ [
204
+ "template", "v1_current",
205
+ "--image", "ghcr.io/your-org/openhands-php:8.4-v1",
206
+ "--count", "2",
207
+ ]
208
+ )
209
+ derived = json.loads(out.getvalue())
210
+ self.assertNotIn("name", derived)
211
+ self.assertNotIn("source", derived)
212
+ self.assertEqual(derived["image"], "ghcr.io/your-org/openhands-php:8.4-v1")
213
+ self.assertEqual(derived["count"], 2)
214
+ self.assertEqual(derived["environment"], {"BASE": "1"})
215
+
216
+ def test_save_upserts_via_admin_auth(self) -> None:
217
+ body = {
218
+ "image": "ghcr.io/your-org/openhands-php:8.4-v1",
219
+ "count": 1,
220
+ "command": ["x"],
221
+ "working_dir": "/w",
222
+ "environment": {},
223
+ }
224
+ sys.stdin = io.StringIO(json.dumps(body))
225
+ try:
226
+ with self._capture_stdout():
227
+ wrc.main(["save", "php-web"])
228
+ finally:
229
+ sys.stdin = sys.__stdin__
230
+
231
+ self.assertIn("php-web", FakeRuntimeAPI.configs)
232
+ method, name, headers, sent = FakeRuntimeAPI.calls[-1]
233
+ self.assertEqual(method, "PUT")
234
+ self.assertEqual(name, "php-web")
235
+ self.assertEqual(headers.get("Authorization"), f"Bearer {ADMIN_JWT}")
236
+ self.assertEqual(sent["image"], body["image"])
237
+
238
+ def test_save_from_file(self) -> None:
239
+ path = Path(self.id().split(".")[-1] + ".json")
240
+ path.write_text(json.dumps({"image": "x", "command": [], "working_dir": "/", "environment": {}, "count": 1}))
241
+ try:
242
+ with self._capture_stdout():
243
+ wrc.main(["save", "from-file", "--file", str(path)])
244
+ finally:
245
+ path.unlink()
246
+ self.assertIn("from-file", FakeRuntimeAPI.configs)
247
+
248
+ def test_delete_removes_config(self) -> None:
249
+ FakeRuntimeAPI.configs["php-web"] = {"image": "x"}
250
+ with self._capture_stdout():
251
+ wrc.main(["delete", "php-web"])
252
+ self.assertNotIn("php-web", FakeRuntimeAPI.configs)
253
+
254
+ def test_admin_token_env_skips_login(self) -> None:
255
+ FakeRuntimeAPI.configs["php-web"] = {"image": "x"}
256
+ os.environ["ADMIN_TOKEN"] = ADMIN_JWT
257
+ os.environ.pop("ADMIN_PASSWORD", None) # would fail if handshake ran
258
+ try:
259
+ with self._capture_stdout():
260
+ wrc.main(["delete", "php-web"])
261
+ # Handshake would have called /api/admin/challenge; verify it didn't.
262
+ self.assertNotIn("php-web", FakeRuntimeAPI.configs)
263
+ self.assertEqual(FakeRuntimeAPI.calls[-1][0], "DELETE")
264
+ finally:
265
+ os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
266
+
267
+ def test_missing_env_fails_fast(self) -> None:
268
+ os.environ.pop("RUNTIME_API_URL", None)
269
+ try:
270
+ with self.assertRaises(SystemExit) as cm:
271
+ wrc.main(["list"])
272
+ self.assertEqual(cm.exception.code, 1)
273
+ finally:
274
+ host, port = self.server.server_address
275
+ os.environ["RUNTIME_API_URL"] = f"http://{host}:{port}"
276
+
277
+ def test_list_falls_back_to_admin_when_api_key_missing(self) -> None:
278
+ """Without API_KEY set, list must log in as admin and fetch it."""
279
+ os.environ.pop("API_KEY", None)
280
+ try:
281
+ with self._capture_stdout() as out:
282
+ wrc.main(["list"])
283
+ self.assertEqual(
284
+ [c["name"] for c in json.loads(out.getvalue())], ["v1_current"]
285
+ )
286
+ # Fallback path hit /api/admin/api-keys with the admin JWT.
287
+ paths = [c[1] for c in FakeRuntimeAPI.calls]
288
+ self.assertIn("/api/admin/api-keys", paths)
289
+ finally:
290
+ os.environ["API_KEY"] = API_KEY
291
+
292
+ def test_list_fails_when_no_api_key_and_no_admin_creds(self) -> None:
293
+ """No fallback possible: neither API_KEY nor admin creds set."""
294
+ os.environ.pop("API_KEY", None)
295
+ os.environ.pop("ADMIN_PASSWORD", None)
296
+ os.environ.pop("ADMIN_TOKEN", None)
297
+ try:
298
+ with self.assertRaises(SystemExit) as cm:
299
+ wrc.main(["list"])
300
+ self.assertEqual(cm.exception.code, 1)
301
+ finally:
302
+ os.environ["API_KEY"] = API_KEY
303
+ os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
304
+
305
+ def test_bootstrap_shell_quotes_hostile_secret_values(self) -> None:
306
+ """Guards against `eval` breakout when a secret contains shell metacharacters."""
307
+ hostile_password = "p@ss word;echo INJECTED"
308
+ hostile_key = "key$(touch /tmp/pwned)"
309
+
310
+ original_which = wrc.shutil.which
311
+ original_decode = wrc._decode_secret
312
+ wrc.shutil.which = lambda _cmd: "/usr/bin/kubectl"
313
+ wrc._decode_secret = lambda _ns, secret, _key: (
314
+ hostile_password if secret == "admin-password" else hostile_key
315
+ )
316
+ try:
317
+ with self._capture_stdout() as out:
318
+ wrc.main(["bootstrap", "--skip-url"])
319
+ rendered = out.getvalue()
320
+ finally:
321
+ wrc.shutil.which = original_which
322
+ wrc._decode_secret = original_decode
323
+
324
+ # Parse the emitted export lines through the shell's own tokenizer,
325
+ # then verify each variable ends up with the exact original value.
326
+ env: dict[str, str] = {}
327
+ for line in rendered.strip().splitlines():
328
+ tokens = __import__("shlex").split(line)
329
+ assert tokens[0] == "export" and len(tokens) == 2, tokens
330
+ key, _, value = tokens[1].partition("=")
331
+ env[key] = value
332
+
333
+ self.assertEqual(env["ADMIN_PASSWORD"], hostile_password)
334
+ self.assertEqual(env["API_KEY"], hostile_key)
335
+
336
+
337
+ if __name__ == "__main__":
338
+ unittest.main()
@@ -2,22 +2,37 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import time
5
6
  from urllib.error import HTTPError
6
7
  from urllib.request import Request, urlopen
7
- from uuid import NAMESPACE_URL, UUID, uuid5
8
-
9
- import httpx
10
- from openhands.sdk import RemoteConversation
11
- from openhands.sdk.conversation.request import (
12
- SendMessageRequest,
13
- StartConversationRequest,
14
- )
15
- from openhands.sdk.conversation.state import ConversationExecutionStatus
16
- from openhands.sdk.llm.message import TextContent
17
- from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
8
+ from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
9
+
10
+ try:
11
+ import httpx
12
+ from openhands.sdk import RemoteConversation
13
+ from openhands.sdk.conversation.request import (
14
+ SendMessageRequest,
15
+ StartConversationRequest,
16
+ )
17
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
18
+ from openhands.sdk.llm.message import TextContent
19
+ from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
20
+ except ImportError:
21
+ # A run on OpenHands Cloud or Enterprise has no SDK on its Python path and
22
+ # does not need one: CloudConversations speaks the OpenHands API instead.
23
+ httpx = RemoteConversation = SendMessageRequest = None
24
+ StartConversationRequest = ConversationExecutionStatus = None
25
+ TextContent = LocalWorkspace = RemoteWorkspace = None
18
26
 
19
27
  _CONVERSATION_KEY_PREFIX = "agent-conversation-"
20
28
 
29
+ # How long a conversation the OpenHands API was asked to start may take to
30
+ # appear before the start is taken to have failed and is made again.
31
+ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
+ # How long to wait for a paused conversation's sandbox to come back up.
33
+ _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
+ _CLOUD_POLL_SECONDS = 3
35
+
21
36
 
22
37
  def _register_tools() -> None:
23
38
  """Register tool models needed to deserialize an attached agent."""
@@ -50,19 +65,106 @@ def _kv_request(key: str, method: str, value: dict | None = None) -> dict | None
50
65
  return body.get("value") if method == "GET" else body
51
66
 
52
67
 
68
+ class CloudConversations:
69
+ """The conversations of a run on OpenHands Cloud or Enterprise.
70
+
71
+ Such a run has no Agent Server to attach to. The OpenHands API starts each
72
+ conversation in a sandbox of its own, which outlives the run's, with the
73
+ agent profile's model, tools and secrets resolved on the server.
74
+ """
75
+
76
+ def __init__(self) -> None:
77
+ self.api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
78
+ self.headers = {
79
+ "Authorization": f"Bearer {os.environ['OPENHANDS_API_KEY']}",
80
+ "Content-Type": "application/json",
81
+ }
82
+
83
+ def _request(self, method: str, url: str, body=None, headers=None):
84
+ request = Request(
85
+ url if url.startswith("http") else f"{self.api}{url}",
86
+ data=json.dumps(body).encode() if body is not None else None,
87
+ headers=headers or self.headers,
88
+ method=method,
89
+ )
90
+ with urlopen(request, timeout=90) as response:
91
+ raw = response.read()
92
+ return json.loads(raw) if raw.strip() else {}
93
+
94
+ def get(self, conversation_id) -> dict | None:
95
+ """The conversation, or None while the API has no record of it."""
96
+ found = self._request("GET", f"/api/v1/app-conversations?ids={conversation_id}")
97
+ return found[0] if found else None
98
+
99
+ def start(self, conversation_id, profile_id, title: str, prompt: str) -> None:
100
+ self._request(
101
+ "POST",
102
+ "/api/v1/app-conversations",
103
+ {
104
+ "conversation_id": str(conversation_id),
105
+ "agent_profile_id": str(profile_id),
106
+ "title": title,
107
+ "initial_message": _user_message(prompt),
108
+ },
109
+ )
110
+
111
+ def run(self, conversation: dict) -> None:
112
+ """Start the next turn of an idle conversation on its own Agent Server."""
113
+ try:
114
+ self._request(
115
+ "POST",
116
+ f"{conversation['conversation_url']}/run",
117
+ headers={"X-Session-API-Key": conversation["session_api_key"]},
118
+ )
119
+ except HTTPError as exc:
120
+ if exc.code != 409: # 409: a turn is already running
121
+ raise
122
+
123
+ def send(self, conversation: dict, prompt: str) -> None:
124
+ """Send the next turn, waking the conversation's sandbox if it is paused."""
125
+ conversation_id = conversation["id"]
126
+ if conversation.get("sandbox_status") != "RUNNING":
127
+ self._request(
128
+ "POST", f"/api/v1/sandboxes/{conversation['sandbox_id']}/resume"
129
+ )
130
+ deadline = time.monotonic() + _CLOUD_RESUME_TIMEOUT_SECONDS
131
+ # The execution status is only reported once the sandbox's Agent
132
+ # Server answers, which is when it can take the message.
133
+ while not (self.get(conversation_id) or {}).get("execution_status"):
134
+ if time.monotonic() > deadline:
135
+ raise RuntimeError(
136
+ f"Conversation {conversation_id} did not resume in time"
137
+ )
138
+ time.sleep(_CLOUD_POLL_SECONDS)
139
+ self._request(
140
+ "POST",
141
+ f"/api/v1/app-conversations/{conversation_id}/send-message",
142
+ {**_user_message(prompt), "run": True},
143
+ )
144
+
145
+
146
+ def _user_message(text: str) -> dict:
147
+ return {"role": "user", "content": [{"type": "text", "text": text}]}
148
+
149
+
53
150
  class AgentConversationDispatcher:
54
151
  """Deliver one revision at a time to a stable conversation for each subject."""
55
152
 
56
153
  def __init__(self) -> None:
57
- self.agent_url = os.environ["AGENT_SERVER_URL"]
154
+ # The automation service hands a run the Agent Server URL only on a
155
+ # local Agent Canvas; elsewhere conversations go through the OpenHands API.
156
+ self.agent_url = os.environ.get("AGENT_SERVER_URL")
58
157
  self.api_key = os.environ["SESSION_API_KEY"]
59
158
  self.profile_id = UUID(os.environ["AUTOMATION_AGENT_PROFILE_ID"])
60
159
  payload = json.loads(os.environ["AUTOMATION_EVENT_PAYLOAD"])
61
160
  self.automation_id = str(payload["automation_id"])
161
+ self._cloud = None if self.agent_url else CloudConversations()
62
162
  self._workspace: RemoteWorkspace | None = None
63
163
  self._secrets = {}
64
164
 
65
165
  def __enter__(self):
166
+ if self._cloud:
167
+ return self
66
168
  _register_tools()
67
169
  self._workspace = RemoteWorkspace(
68
170
  host=self.agent_url,
@@ -76,17 +178,107 @@ class AgentConversationDispatcher:
76
178
  return self
77
179
 
78
180
  def __exit__(self, *args):
181
+ if self._cloud:
182
+ return None
79
183
  assert self._workspace is not None
80
184
  return self._workspace.__exit__(*args)
81
185
 
82
- def deliver(self, subject: str, delivery: str, prompt: str) -> dict[str, str]:
186
+ def deliver(
187
+ self, subject: str, delivery: str, prompt: str, head: str = ""
188
+ ) -> dict[str, str]:
189
+ """Deliver one revision of `subject`, deduping a repeated head.
190
+
191
+ `delivery` keys the revision: a second trigger for the same revision
192
+ reuses the conversation without a new turn, and a changed head becomes a
193
+ new delivery. `head` is the revision's commit, and it is the guard that
194
+ matters when two triggers name the same commit - a re-request after the
195
+ bot's own handoff, say - because a delivery key that only counts triggers
196
+ would send a second turn and publish a second review of identical code.
197
+ A conversation already running for this head is reported `in_progress`
198
+ and left alone, whatever the new delivery says. Callers with no revision
199
+ identity leave `head` empty and keep the delivery-key-only behavior.
200
+ """
83
201
  conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
84
202
  state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
85
203
  record = _kv_request(state_key, "GET") or {}
86
- if self._workspace is None:
87
- raise RuntimeError("AgentConversationDispatcher must be used as a context")
88
204
 
89
205
  same_delivery = record.get("delivery") == delivery
206
+ same_head = bool(head) and record.get("head") == head
207
+
208
+ if self._cloud:
209
+ disposition, conversation_id = self._deliver_cloud(
210
+ record, conversation_id, subject, prompt, same_delivery, same_head
211
+ )
212
+ else:
213
+ disposition = self._deliver_local(
214
+ conversation_id, prompt, same_delivery, same_head
215
+ )
216
+
217
+ if disposition in ("deduplicated", "in_progress"):
218
+ return {
219
+ "disposition": disposition,
220
+ "conversation_id": str(conversation_id),
221
+ }
222
+
223
+ new_record = {
224
+ "subject": subject,
225
+ "conversation_id": str(conversation_id),
226
+ "delivery": delivery,
227
+ "head": head or record.get("head") or "",
228
+ }
229
+ if disposition == "created" and self._cloud:
230
+ new_record["started_at"] = time.time()
231
+ _kv_request(state_key, "PUT", new_record)
232
+ return {
233
+ "disposition": disposition,
234
+ "conversation_id": str(conversation_id),
235
+ }
236
+
237
+ def _deliver_cloud(
238
+ self, record, conversation_id, subject, prompt, same_delivery, same_head
239
+ ):
240
+ """Deliver through the OpenHands API; return the disposition and the id.
241
+
242
+ The id is the subject's stable one until its conversation can no longer
243
+ take a turn - its start never completed, or its sandbox is gone - and a
244
+ fresh id from then on, because the API does not start an id twice. The
245
+ KV record carries whichever is current.
246
+ """
247
+ current_id = record.get("conversation_id") or conversation_id
248
+ conversation = self._cloud.get(current_id)
249
+
250
+ if conversation is None:
251
+ started = time.time() - float(record.get("started_at") or 0)
252
+ if started < _CLOUD_START_GRACE_SECONDS:
253
+ # The API is still bringing the subject's conversation up and
254
+ # does not list it yet. Starting another would run two for one
255
+ # subject, so even a new revision waits: the record is left as
256
+ # it is and the next trigger delivers it.
257
+ return "in_progress", current_id
258
+ elif conversation.get("sandbox_status") in ("ERROR", "MISSING"):
259
+ if same_delivery:
260
+ return "deduplicated", current_id
261
+ conversation = None
262
+
263
+ if conversation is None:
264
+ new_id = uuid4() if record else conversation_id
265
+ self._cloud.start(new_id, self.profile_id, subject, prompt)
266
+ return "created", new_id
267
+
268
+ status = conversation.get("execution_status")
269
+ if status == "running" and (same_delivery or same_head):
270
+ return "in_progress", current_id
271
+ if same_delivery:
272
+ if status in ("idle", "paused"):
273
+ self._cloud.run(conversation)
274
+ return "resumed", current_id
275
+ return "deduplicated", current_id
276
+ self._cloud.send(conversation, prompt)
277
+ return "resumed", current_id
278
+
279
+ def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
280
+ if self._workspace is None:
281
+ raise RuntimeError("AgentConversationDispatcher must be used as a context")
90
282
 
91
283
  try:
92
284
  conversation = RemoteConversation.attach(
@@ -112,14 +304,18 @@ class AgentConversationDispatcher:
112
304
  disposition = "created"
113
305
  try:
114
306
  if disposition == "resumed":
115
- if same_delivery:
116
- if (
117
- conversation.state.execution_status
118
- == ConversationExecutionStatus.RUNNING
119
- ):
120
- conversation.update_secrets(self._secrets)
121
- disposition = "in_progress"
122
- elif conversation.state.execution_status in (
307
+ running = (
308
+ conversation.state.execution_status
309
+ == ConversationExecutionStatus.RUNNING
310
+ )
311
+ if running and (same_delivery or same_head):
312
+ # A turn is already running for this very revision, so a new
313
+ # trigger is not new work: report the live conversation
314
+ # rather than starting a second pass beside it.
315
+ conversation.update_secrets(self._secrets)
316
+ disposition = "in_progress"
317
+ elif same_delivery:
318
+ if conversation.state.execution_status in (
123
319
  ConversationExecutionStatus.IDLE,
124
320
  ConversationExecutionStatus.PAUSED,
125
321
  ):
@@ -128,28 +324,12 @@ class AgentConversationDispatcher:
128
324
  else:
129
325
  disposition = "deduplicated"
130
326
  else:
327
+ # A changed head, or a head a human clarified since, is new
328
+ # work on the same subject: reuse the conversation and send
329
+ # the revision as its next turn.
131
330
  conversation.update_secrets(self._secrets)
132
331
  conversation.send_message(prompt)
133
332
  conversation.run(blocking=False)
134
333
  finally:
135
334
  conversation.close()
136
-
137
- if disposition in ("deduplicated", "in_progress"):
138
- return {
139
- "disposition": disposition,
140
- "conversation_id": str(conversation_id),
141
- }
142
-
143
- _kv_request(
144
- state_key,
145
- "PUT",
146
- {
147
- "subject": subject,
148
- "conversation_id": str(conversation_id),
149
- "delivery": delivery,
150
- },
151
- )
152
- return {
153
- "disposition": disposition,
154
- "conversation_id": str(conversation_id),
155
- }
335
+ return disposition