@openhands/extensions 0.27.0 → 0.29.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.
- package/.agents/skills/custom-codereview-guide.md +31 -0
- package/.claude-plugin/marketplace.json +9 -0
- package/.codex-plugin/marketplace.json +9 -0
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +2 -2
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +9 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -2
- package/automations/bundle-index.js +5 -5
- package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
- package/automations/catalog/github-issue-triage/manifest.json +2 -2
- package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
- package/marketplaces/openhands-extensions.json +9 -0
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/README.md +6 -4
- package/plugins/openhands/skills/openhands-automation/SKILL.md +103 -21
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +79 -12
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +2 -1
- package/plugins/qa-changes/README.md +4 -0
- package/plugins/qa-changes/skills/qa-changes/README.md +3 -1
- package/plugins/qa-changes/skills/qa-changes/SKILL.md +42 -0
- package/plugins/vulnerability-remediation/skills/github/README.md +5 -1
- package/plugins/vulnerability-remediation/skills/github/SKILL.md +27 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +51 -4
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/sync_openhands_sdk_skill.py +16 -13
- package/skills/azure-devops/README.md +3 -1
- package/skills/azure-devops/SKILL.md +4 -0
- package/skills/bitbucket/README.md +3 -1
- package/skills/bitbucket/SKILL.md +4 -2
- package/skills/bitbucket-cloud/README.md +2 -0
- package/skills/bitbucket-cloud/SKILL.md +2 -0
- package/skills/bitbucket-data-center/README.md +2 -0
- package/skills/bitbucket-data-center/SKILL.md +2 -0
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/github/README.md +5 -1
- package/skills/github/SKILL.md +27 -0
- package/skills/github/scripts/agent_conversation.py +51 -4
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +51 -4
- package/skills/github-issue-triage/scripts/agent_conversation.py +51 -4
- package/skills/github-pr-reviewer/README.md +5 -0
- package/skills/github-pr-reviewer/SKILL.md +12 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +51 -4
- package/skills/github-pr-reviewer/scripts/main.py +20 -6
- package/skills/github-pr-reviewer/scripts/worker.py +77 -15
- package/skills/github-pr-reviewer/tests/test_main.py +23 -0
- package/skills/gitlab/README.md +3 -1
- package/skills/gitlab/SKILL.md +4 -0
- package/skills/index.js +25 -13
- package/skills/linear/README.md +2 -0
- package/skills/linear/SKILL.md +6 -2
- package/skills/openhands-automation/README.md +6 -4
- package/skills/openhands-automation/SKILL.md +103 -21
- package/skills/openhands-automation/references/custom-automation.md +79 -12
- package/skills/openhands-sdk/SKILL.md +2 -1
- package/skills/qa-changes/README.md +3 -1
- package/skills/qa-changes/SKILL.md +42 -0
- package/skills/simplified-technical-english/.claude-plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/.codex-plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/.plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/README.md +21 -0
- package/skills/simplified-technical-english/SKILL.md +65 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +130 -0
- package/tests/test_github_reviewer_delivery.py +279 -2
|
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
|
|
|
32
32
|
# How long to wait for a paused conversation's sandbox to come back up.
|
|
33
33
|
_CLOUD_RESUME_TIMEOUT_SECONDS = 120
|
|
34
34
|
_CLOUD_POLL_SECONDS = 3
|
|
35
|
+
# How many times a matched delivery whose conversation ended in ERROR is sent
|
|
36
|
+
# again. The delivery key never changes for a stable subject, so without a
|
|
37
|
+
# retry its work is stranded; without a bound, a conversation that fails the
|
|
38
|
+
# same way every time would be re-run on every scan.
|
|
39
|
+
_MAX_ERROR_RETRIES = 2
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
def _register_tools() -> None:
|
|
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
|
|
|
204
209
|
|
|
205
210
|
same_delivery = record.get("delivery") == delivery
|
|
206
211
|
same_head = bool(head) and record.get("head") == head
|
|
212
|
+
# Error retries count against one delivery; a new delivery starts over.
|
|
213
|
+
error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
|
|
214
|
+
can_retry = error_retries < _MAX_ERROR_RETRIES
|
|
207
215
|
|
|
208
216
|
if self._cloud:
|
|
209
217
|
disposition, conversation_id = self._deliver_cloud(
|
|
210
|
-
record,
|
|
218
|
+
record,
|
|
219
|
+
conversation_id,
|
|
220
|
+
subject,
|
|
221
|
+
prompt,
|
|
222
|
+
same_delivery,
|
|
223
|
+
same_head,
|
|
224
|
+
can_retry,
|
|
211
225
|
)
|
|
212
226
|
else:
|
|
213
227
|
disposition = self._deliver_local(
|
|
214
|
-
conversation_id, prompt, same_delivery, same_head
|
|
228
|
+
conversation_id, prompt, same_delivery, same_head, can_retry
|
|
215
229
|
)
|
|
216
230
|
|
|
217
231
|
if disposition in ("deduplicated", "in_progress"):
|
|
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
|
|
|
228
242
|
}
|
|
229
243
|
if disposition == "created" and self._cloud:
|
|
230
244
|
new_record["started_at"] = time.time()
|
|
245
|
+
if disposition == "retried":
|
|
246
|
+
error_retries += 1
|
|
247
|
+
if error_retries:
|
|
248
|
+
new_record["error_retries"] = error_retries
|
|
231
249
|
_kv_request(state_key, "PUT", new_record)
|
|
232
250
|
return {
|
|
233
251
|
"disposition": disposition,
|
|
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
def _deliver_cloud(
|
|
238
|
-
self,
|
|
256
|
+
self,
|
|
257
|
+
record,
|
|
258
|
+
conversation_id,
|
|
259
|
+
subject,
|
|
260
|
+
prompt,
|
|
261
|
+
same_delivery,
|
|
262
|
+
same_head,
|
|
263
|
+
can_retry,
|
|
239
264
|
):
|
|
240
265
|
"""Deliver through the OpenHands API; return the disposition and the id.
|
|
241
266
|
|
|
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
|
|
|
272
297
|
if status in ("idle", "paused"):
|
|
273
298
|
self._cloud.run(conversation)
|
|
274
299
|
return "resumed", current_id
|
|
300
|
+
if status == "error" and can_retry:
|
|
301
|
+
# The delivery matched but its conversation died before the work
|
|
302
|
+
# finished, so send the revision again rather than strand it.
|
|
303
|
+
# The API reports an execution status only while the sandbox
|
|
304
|
+
# runs, so an errored conversation whose sandbox was already
|
|
305
|
+
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
+
self._cloud.send(conversation, prompt)
|
|
307
|
+
return "retried", current_id
|
|
275
308
|
return "deduplicated", current_id
|
|
276
309
|
self._cloud.send(conversation, prompt)
|
|
277
310
|
return "resumed", current_id
|
|
278
311
|
|
|
279
|
-
def _deliver_local(
|
|
312
|
+
def _deliver_local(
|
|
313
|
+
self, conversation_id, prompt, same_delivery, same_head, can_retry
|
|
314
|
+
) -> str:
|
|
280
315
|
if self._workspace is None:
|
|
281
316
|
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
282
317
|
|
|
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
|
|
|
321
356
|
):
|
|
322
357
|
conversation.update_secrets(self._secrets)
|
|
323
358
|
conversation.run(blocking=False)
|
|
359
|
+
elif (
|
|
360
|
+
conversation.state.execution_status
|
|
361
|
+
== ConversationExecutionStatus.ERROR
|
|
362
|
+
and can_retry
|
|
363
|
+
):
|
|
364
|
+
# The delivery matched but its conversation died before
|
|
365
|
+
# the work finished, so send the revision again rather
|
|
366
|
+
# than strand it.
|
|
367
|
+
conversation.update_secrets(self._secrets)
|
|
368
|
+
conversation.send_message(prompt)
|
|
369
|
+
conversation.run(blocking=False)
|
|
370
|
+
disposition = "retried"
|
|
324
371
|
else:
|
|
325
372
|
disposition = "deduplicated"
|
|
326
373
|
else:
|
|
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
|
|
|
32
32
|
# How long to wait for a paused conversation's sandbox to come back up.
|
|
33
33
|
_CLOUD_RESUME_TIMEOUT_SECONDS = 120
|
|
34
34
|
_CLOUD_POLL_SECONDS = 3
|
|
35
|
+
# How many times a matched delivery whose conversation ended in ERROR is sent
|
|
36
|
+
# again. The delivery key never changes for a stable subject, so without a
|
|
37
|
+
# retry its work is stranded; without a bound, a conversation that fails the
|
|
38
|
+
# same way every time would be re-run on every scan.
|
|
39
|
+
_MAX_ERROR_RETRIES = 2
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
def _register_tools() -> None:
|
|
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
|
|
|
204
209
|
|
|
205
210
|
same_delivery = record.get("delivery") == delivery
|
|
206
211
|
same_head = bool(head) and record.get("head") == head
|
|
212
|
+
# Error retries count against one delivery; a new delivery starts over.
|
|
213
|
+
error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
|
|
214
|
+
can_retry = error_retries < _MAX_ERROR_RETRIES
|
|
207
215
|
|
|
208
216
|
if self._cloud:
|
|
209
217
|
disposition, conversation_id = self._deliver_cloud(
|
|
210
|
-
record,
|
|
218
|
+
record,
|
|
219
|
+
conversation_id,
|
|
220
|
+
subject,
|
|
221
|
+
prompt,
|
|
222
|
+
same_delivery,
|
|
223
|
+
same_head,
|
|
224
|
+
can_retry,
|
|
211
225
|
)
|
|
212
226
|
else:
|
|
213
227
|
disposition = self._deliver_local(
|
|
214
|
-
conversation_id, prompt, same_delivery, same_head
|
|
228
|
+
conversation_id, prompt, same_delivery, same_head, can_retry
|
|
215
229
|
)
|
|
216
230
|
|
|
217
231
|
if disposition in ("deduplicated", "in_progress"):
|
|
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
|
|
|
228
242
|
}
|
|
229
243
|
if disposition == "created" and self._cloud:
|
|
230
244
|
new_record["started_at"] = time.time()
|
|
245
|
+
if disposition == "retried":
|
|
246
|
+
error_retries += 1
|
|
247
|
+
if error_retries:
|
|
248
|
+
new_record["error_retries"] = error_retries
|
|
231
249
|
_kv_request(state_key, "PUT", new_record)
|
|
232
250
|
return {
|
|
233
251
|
"disposition": disposition,
|
|
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
def _deliver_cloud(
|
|
238
|
-
self,
|
|
256
|
+
self,
|
|
257
|
+
record,
|
|
258
|
+
conversation_id,
|
|
259
|
+
subject,
|
|
260
|
+
prompt,
|
|
261
|
+
same_delivery,
|
|
262
|
+
same_head,
|
|
263
|
+
can_retry,
|
|
239
264
|
):
|
|
240
265
|
"""Deliver through the OpenHands API; return the disposition and the id.
|
|
241
266
|
|
|
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
|
|
|
272
297
|
if status in ("idle", "paused"):
|
|
273
298
|
self._cloud.run(conversation)
|
|
274
299
|
return "resumed", current_id
|
|
300
|
+
if status == "error" and can_retry:
|
|
301
|
+
# The delivery matched but its conversation died before the work
|
|
302
|
+
# finished, so send the revision again rather than strand it.
|
|
303
|
+
# The API reports an execution status only while the sandbox
|
|
304
|
+
# runs, so an errored conversation whose sandbox was already
|
|
305
|
+
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
+
self._cloud.send(conversation, prompt)
|
|
307
|
+
return "retried", current_id
|
|
275
308
|
return "deduplicated", current_id
|
|
276
309
|
self._cloud.send(conversation, prompt)
|
|
277
310
|
return "resumed", current_id
|
|
278
311
|
|
|
279
|
-
def _deliver_local(
|
|
312
|
+
def _deliver_local(
|
|
313
|
+
self, conversation_id, prompt, same_delivery, same_head, can_retry
|
|
314
|
+
) -> str:
|
|
280
315
|
if self._workspace is None:
|
|
281
316
|
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
282
317
|
|
|
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
|
|
|
321
356
|
):
|
|
322
357
|
conversation.update_secrets(self._secrets)
|
|
323
358
|
conversation.run(blocking=False)
|
|
359
|
+
elif (
|
|
360
|
+
conversation.state.execution_status
|
|
361
|
+
== ConversationExecutionStatus.ERROR
|
|
362
|
+
and can_retry
|
|
363
|
+
):
|
|
364
|
+
# The delivery matched but its conversation died before
|
|
365
|
+
# the work finished, so send the revision again rather
|
|
366
|
+
# than strand it.
|
|
367
|
+
conversation.update_secrets(self._secrets)
|
|
368
|
+
conversation.send_message(prompt)
|
|
369
|
+
conversation.run(blocking=False)
|
|
370
|
+
disposition = "retried"
|
|
324
371
|
else:
|
|
325
372
|
disposition = "deduplicated"
|
|
326
373
|
else:
|
|
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
|
|
|
32
32
|
# How long to wait for a paused conversation's sandbox to come back up.
|
|
33
33
|
_CLOUD_RESUME_TIMEOUT_SECONDS = 120
|
|
34
34
|
_CLOUD_POLL_SECONDS = 3
|
|
35
|
+
# How many times a matched delivery whose conversation ended in ERROR is sent
|
|
36
|
+
# again. The delivery key never changes for a stable subject, so without a
|
|
37
|
+
# retry its work is stranded; without a bound, a conversation that fails the
|
|
38
|
+
# same way every time would be re-run on every scan.
|
|
39
|
+
_MAX_ERROR_RETRIES = 2
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
def _register_tools() -> None:
|
|
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
|
|
|
204
209
|
|
|
205
210
|
same_delivery = record.get("delivery") == delivery
|
|
206
211
|
same_head = bool(head) and record.get("head") == head
|
|
212
|
+
# Error retries count against one delivery; a new delivery starts over.
|
|
213
|
+
error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
|
|
214
|
+
can_retry = error_retries < _MAX_ERROR_RETRIES
|
|
207
215
|
|
|
208
216
|
if self._cloud:
|
|
209
217
|
disposition, conversation_id = self._deliver_cloud(
|
|
210
|
-
record,
|
|
218
|
+
record,
|
|
219
|
+
conversation_id,
|
|
220
|
+
subject,
|
|
221
|
+
prompt,
|
|
222
|
+
same_delivery,
|
|
223
|
+
same_head,
|
|
224
|
+
can_retry,
|
|
211
225
|
)
|
|
212
226
|
else:
|
|
213
227
|
disposition = self._deliver_local(
|
|
214
|
-
conversation_id, prompt, same_delivery, same_head
|
|
228
|
+
conversation_id, prompt, same_delivery, same_head, can_retry
|
|
215
229
|
)
|
|
216
230
|
|
|
217
231
|
if disposition in ("deduplicated", "in_progress"):
|
|
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
|
|
|
228
242
|
}
|
|
229
243
|
if disposition == "created" and self._cloud:
|
|
230
244
|
new_record["started_at"] = time.time()
|
|
245
|
+
if disposition == "retried":
|
|
246
|
+
error_retries += 1
|
|
247
|
+
if error_retries:
|
|
248
|
+
new_record["error_retries"] = error_retries
|
|
231
249
|
_kv_request(state_key, "PUT", new_record)
|
|
232
250
|
return {
|
|
233
251
|
"disposition": disposition,
|
|
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
def _deliver_cloud(
|
|
238
|
-
self,
|
|
256
|
+
self,
|
|
257
|
+
record,
|
|
258
|
+
conversation_id,
|
|
259
|
+
subject,
|
|
260
|
+
prompt,
|
|
261
|
+
same_delivery,
|
|
262
|
+
same_head,
|
|
263
|
+
can_retry,
|
|
239
264
|
):
|
|
240
265
|
"""Deliver through the OpenHands API; return the disposition and the id.
|
|
241
266
|
|
|
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
|
|
|
272
297
|
if status in ("idle", "paused"):
|
|
273
298
|
self._cloud.run(conversation)
|
|
274
299
|
return "resumed", current_id
|
|
300
|
+
if status == "error" and can_retry:
|
|
301
|
+
# The delivery matched but its conversation died before the work
|
|
302
|
+
# finished, so send the revision again rather than strand it.
|
|
303
|
+
# The API reports an execution status only while the sandbox
|
|
304
|
+
# runs, so an errored conversation whose sandbox was already
|
|
305
|
+
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
+
self._cloud.send(conversation, prompt)
|
|
307
|
+
return "retried", current_id
|
|
275
308
|
return "deduplicated", current_id
|
|
276
309
|
self._cloud.send(conversation, prompt)
|
|
277
310
|
return "resumed", current_id
|
|
278
311
|
|
|
279
|
-
def _deliver_local(
|
|
312
|
+
def _deliver_local(
|
|
313
|
+
self, conversation_id, prompt, same_delivery, same_head, can_retry
|
|
314
|
+
) -> str:
|
|
280
315
|
if self._workspace is None:
|
|
281
316
|
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
282
317
|
|
|
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
|
|
|
321
356
|
):
|
|
322
357
|
conversation.update_secrets(self._secrets)
|
|
323
358
|
conversation.run(blocking=False)
|
|
359
|
+
elif (
|
|
360
|
+
conversation.state.execution_status
|
|
361
|
+
== ConversationExecutionStatus.ERROR
|
|
362
|
+
and can_retry
|
|
363
|
+
):
|
|
364
|
+
# The delivery matched but its conversation died before
|
|
365
|
+
# the work finished, so send the revision again rather
|
|
366
|
+
# than strand it.
|
|
367
|
+
conversation.update_secrets(self._secrets)
|
|
368
|
+
conversation.send_message(prompt)
|
|
369
|
+
conversation.run(blocking=False)
|
|
370
|
+
disposition = "retried"
|
|
324
371
|
else:
|
|
325
372
|
disposition = "deduplicated"
|
|
326
373
|
else:
|
|
@@ -61,6 +61,11 @@ This skill is activated by:
|
|
|
61
61
|
checkout when the review ends, so nothing accumulates between runs
|
|
62
62
|
- Publishes a real pull request review, with inline comments where a finding
|
|
63
63
|
maps to a changed line, and verifies on GitHub that it landed
|
|
64
|
+
- Requires live evidence from the real app before approving a user-visible UI
|
|
65
|
+
change when the repository's guidance demands it: unit tests, CSS-token
|
|
66
|
+
assertions, generated mockups, and reconstructed captures cannot substitute.
|
|
67
|
+
Missing evidence yields a COMMENT review that names the gap, so no approval
|
|
68
|
+
and no maintainer handoff
|
|
64
69
|
- Posts acknowledgement comments with AI disclosure
|
|
65
70
|
- Configurable review tone and polling schedule
|
|
66
71
|
- Optional human handoff after an exact-head approval. The scanner ranks the
|
|
@@ -189,6 +189,18 @@ handler recognizes the verdict and requests one configured maintainer through th
|
|
|
189
189
|
same handoff used after an approval. An in-scope change continues the existing
|
|
190
190
|
review unchanged.
|
|
191
191
|
|
|
192
|
+
Before approving a change to user-visible UI behavior, the prompt requires live
|
|
193
|
+
evidence from a real running application when the repository's guidance demands
|
|
194
|
+
it: a screenshot, screen recording, or equivalent capture of the running app
|
|
195
|
+
that exercises the production-facing path. Unit tests, CSS-token or contract
|
|
196
|
+
assertions, generated mockups, and reconstructed captures may support the review
|
|
197
|
+
but cannot substitute for that evidence. When the required evidence is missing,
|
|
198
|
+
the reviewer publishes one `event: COMMENT` review naming exactly what is
|
|
199
|
+
missing and ends with `🔄 CHANGES REQUESTED`, so approval is withheld and the
|
|
200
|
+
deterministic maintainer handoff does not fire. Non-UI changes keep the existing
|
|
201
|
+
requirement of the real command and its observed output, with tests insufficient
|
|
202
|
+
as sole proof.
|
|
203
|
+
|
|
192
204
|
The script prepares each review's workspace before the agent starts: the pull
|
|
193
205
|
request's head commit is downloaded as a tarball and extracted to a directory of
|
|
194
206
|
its own, which becomes the conversation's working directory. The agent is told
|
|
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
|
|
|
32
32
|
# How long to wait for a paused conversation's sandbox to come back up.
|
|
33
33
|
_CLOUD_RESUME_TIMEOUT_SECONDS = 120
|
|
34
34
|
_CLOUD_POLL_SECONDS = 3
|
|
35
|
+
# How many times a matched delivery whose conversation ended in ERROR is sent
|
|
36
|
+
# again. The delivery key never changes for a stable subject, so without a
|
|
37
|
+
# retry its work is stranded; without a bound, a conversation that fails the
|
|
38
|
+
# same way every time would be re-run on every scan.
|
|
39
|
+
_MAX_ERROR_RETRIES = 2
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
def _register_tools() -> None:
|
|
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
|
|
|
204
209
|
|
|
205
210
|
same_delivery = record.get("delivery") == delivery
|
|
206
211
|
same_head = bool(head) and record.get("head") == head
|
|
212
|
+
# Error retries count against one delivery; a new delivery starts over.
|
|
213
|
+
error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
|
|
214
|
+
can_retry = error_retries < _MAX_ERROR_RETRIES
|
|
207
215
|
|
|
208
216
|
if self._cloud:
|
|
209
217
|
disposition, conversation_id = self._deliver_cloud(
|
|
210
|
-
record,
|
|
218
|
+
record,
|
|
219
|
+
conversation_id,
|
|
220
|
+
subject,
|
|
221
|
+
prompt,
|
|
222
|
+
same_delivery,
|
|
223
|
+
same_head,
|
|
224
|
+
can_retry,
|
|
211
225
|
)
|
|
212
226
|
else:
|
|
213
227
|
disposition = self._deliver_local(
|
|
214
|
-
conversation_id, prompt, same_delivery, same_head
|
|
228
|
+
conversation_id, prompt, same_delivery, same_head, can_retry
|
|
215
229
|
)
|
|
216
230
|
|
|
217
231
|
if disposition in ("deduplicated", "in_progress"):
|
|
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
|
|
|
228
242
|
}
|
|
229
243
|
if disposition == "created" and self._cloud:
|
|
230
244
|
new_record["started_at"] = time.time()
|
|
245
|
+
if disposition == "retried":
|
|
246
|
+
error_retries += 1
|
|
247
|
+
if error_retries:
|
|
248
|
+
new_record["error_retries"] = error_retries
|
|
231
249
|
_kv_request(state_key, "PUT", new_record)
|
|
232
250
|
return {
|
|
233
251
|
"disposition": disposition,
|
|
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
def _deliver_cloud(
|
|
238
|
-
self,
|
|
256
|
+
self,
|
|
257
|
+
record,
|
|
258
|
+
conversation_id,
|
|
259
|
+
subject,
|
|
260
|
+
prompt,
|
|
261
|
+
same_delivery,
|
|
262
|
+
same_head,
|
|
263
|
+
can_retry,
|
|
239
264
|
):
|
|
240
265
|
"""Deliver through the OpenHands API; return the disposition and the id.
|
|
241
266
|
|
|
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
|
|
|
272
297
|
if status in ("idle", "paused"):
|
|
273
298
|
self._cloud.run(conversation)
|
|
274
299
|
return "resumed", current_id
|
|
300
|
+
if status == "error" and can_retry:
|
|
301
|
+
# The delivery matched but its conversation died before the work
|
|
302
|
+
# finished, so send the revision again rather than strand it.
|
|
303
|
+
# The API reports an execution status only while the sandbox
|
|
304
|
+
# runs, so an errored conversation whose sandbox was already
|
|
305
|
+
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
+
self._cloud.send(conversation, prompt)
|
|
307
|
+
return "retried", current_id
|
|
275
308
|
return "deduplicated", current_id
|
|
276
309
|
self._cloud.send(conversation, prompt)
|
|
277
310
|
return "resumed", current_id
|
|
278
311
|
|
|
279
|
-
def _deliver_local(
|
|
312
|
+
def _deliver_local(
|
|
313
|
+
self, conversation_id, prompt, same_delivery, same_head, can_retry
|
|
314
|
+
) -> str:
|
|
280
315
|
if self._workspace is None:
|
|
281
316
|
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
282
317
|
|
|
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
|
|
|
321
356
|
):
|
|
322
357
|
conversation.update_secrets(self._secrets)
|
|
323
358
|
conversation.run(blocking=False)
|
|
359
|
+
elif (
|
|
360
|
+
conversation.state.execution_status
|
|
361
|
+
== ConversationExecutionStatus.ERROR
|
|
362
|
+
and can_retry
|
|
363
|
+
):
|
|
364
|
+
# The delivery matched but its conversation died before
|
|
365
|
+
# the work finished, so send the revision again rather
|
|
366
|
+
# than strand it.
|
|
367
|
+
conversation.update_secrets(self._secrets)
|
|
368
|
+
conversation.send_message(prompt)
|
|
369
|
+
conversation.run(blocking=False)
|
|
370
|
+
disposition = "retried"
|
|
324
371
|
else:
|
|
325
372
|
disposition = "deduplicated"
|
|
326
373
|
else:
|
|
@@ -847,7 +847,21 @@ def _build_review_prompt(
|
|
|
847
847
|
"credential, configuration, or external state stayed unchanged. Do not add speculative or "
|
|
848
848
|
"out-of-scope notes: every blocking or non-blocking observation must identify demonstrated "
|
|
849
849
|
"behavior on the current head and explain why it matters to the merge decision.\n"
|
|
850
|
-
|
|
850
|
+
"7. LIVE EVIDENCE GATE - if the change alters user-visible UI behavior and the repository "
|
|
851
|
+
"guidance requires live evidence for it, you must not approve unless that evidence is from a "
|
|
852
|
+
"real running application and exercises the production-facing path. Live evidence means a "
|
|
853
|
+
"screenshot, screen recording, or equivalent capture of the running app, not the source, a "
|
|
854
|
+
"diff, or a description of what it should render. Unit tests, CSS-token or contract "
|
|
855
|
+
"assertions, generated mockups, and reconstructed or hand-built captures may support the "
|
|
856
|
+
"review but never substitute for the required live evidence. When the repository guidance "
|
|
857
|
+
"requires that evidence and only non-live evidence is available, do not approve: publish "
|
|
858
|
+
"exactly one review with `event: COMMENT` that names exactly which live evidence is missing, "
|
|
859
|
+
"and end its body with the verdict on its own line: `🔄 CHANGES REQUESTED`. State the gap as "
|
|
860
|
+
"a blocking finding, not a note, and never let a passing test suite stand in for the missing "
|
|
861
|
+
"capture. A UI change that does supply compliant live evidence, and any non-UI change "
|
|
862
|
+
"(backend, API, CLI, script) that follows the existing requirement of the real command and "
|
|
863
|
+
"its observed output, continues to step 8.\n"
|
|
864
|
+
f"8. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using "
|
|
851
865
|
"`commit_id` equal to the Head SHA above. Use `event: APPROVE` when there are "
|
|
852
866
|
"no material findings; otherwise use `event: COMMENT`. Never use "
|
|
853
867
|
"`REQUEST_CHANGES`.\n"
|
|
@@ -858,19 +872,19 @@ def _build_review_prompt(
|
|
|
858
872
|
" Put the overall assessment in `body`, and each line-specific finding in the `comments` "
|
|
859
873
|
"array with `path`, `line`, `side: RIGHT`, and `body`.\n"
|
|
860
874
|
" Only create inline comments for actionable findings; do not open praise or nitpick threads.\n"
|
|
861
|
-
"
|
|
875
|
+
"9. If a finding cannot be attached to a changed line, put it in the review body instead. "
|
|
862
876
|
"If the API rejects the inline positions, retry with every finding in the body and no `comments` array. "
|
|
863
877
|
"If GitHub forbids the configured bot from approving its own PR, retry the clean review with "
|
|
864
878
|
"`event: COMMENT` and keep the approved verdict.\n"
|
|
865
|
-
"
|
|
879
|
+
"10. Begin the review body with this disclosure: "
|
|
866
880
|
"`_This review was posted by an AI agent (OpenHands)._`\n"
|
|
867
|
-
"
|
|
881
|
+
"11. End the review body with a verdict on its own line: either `✅ APPROVED` "
|
|
868
882
|
"or `🔄 CHANGES REQUESTED`.\n"
|
|
869
|
-
"
|
|
883
|
+
"12. If there are no material issues, still publish a review saying so, with the "
|
|
870
884
|
"disclosure and the verdict.\n"
|
|
871
885
|
f"\nReview instructions:\n{tone}{extra}{guide_section}\n\n"
|
|
872
886
|
"After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. "
|
|
873
|
-
"If publishing still fails after the fallback in step
|
|
887
|
+
"If publishing still fails after the fallback in step 9, output the complete review text "
|
|
874
888
|
"so it can be posted as a comment instead."
|
|
875
889
|
)
|
|
876
890
|
|