@openhands/extensions 0.29.0 → 0.30.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 +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +8 -8
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -1,16 +1,75 @@
|
|
|
1
1
|
# GitHub issue triage
|
|
2
2
|
|
|
3
|
-
Prioritize
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
Prioritize issues and define observable acceptance criteria using
|
|
4
|
+
[SKILL.md](SKILL.md). The scanner honors dependencies and sends changed issues
|
|
5
|
+
to conversations using the selected profile.
|
|
6
|
+
|
|
7
|
+
## Publication modes
|
|
8
|
+
|
|
9
|
+
Legacy agent publication is the default. The optional `triage_publication.py`
|
|
10
|
+
helper validates JSON recommendations and checks human inputs before every write.
|
|
11
|
+
It preserves text outside managed markers and edits/deletes only triage-marked
|
|
12
|
+
comments from the authenticated account. It skips identical writes and stops on
|
|
13
|
+
ambiguous markers. GitHub Actions readiness comments provide context, but neither
|
|
14
|
+
change the human-input fingerprint nor override policy.
|
|
15
|
+
|
|
16
|
+
This opt-in is **not credential isolation**: the agent can still write directly.
|
|
17
|
+
Enforced isolation needs a separate trusted publisher, read-only agent credentials,
|
|
18
|
+
and runtime support for passing results to the publisher.
|
|
19
|
+
|
|
20
|
+
## Operator setup
|
|
21
|
+
|
|
22
|
+
Bundle `worker.py`, `triage_publication.py`, `github_client.py`, and
|
|
23
|
+
`agent_conversation.py`. Entrypoint: `python3 worker.py`.
|
|
24
|
+
|
|
25
|
+
1. Place the publisher, `github_client.py`, and protected config in the delegated
|
|
26
|
+
workspace. Set `triage_publisher_path` and `triage_publisher_config_path` to
|
|
27
|
+
absolute file paths. Set both or neither; neither keeps legacy mode.
|
|
28
|
+
2. Set `triage_publisher_workspace: "shared-host"` only after checking manually
|
|
29
|
+
that the agent can access those paths. The flag asserts access; it does not
|
|
30
|
+
prove sandbox visibility. The scanner checks files and the repository allowlist before dispatch, then
|
|
31
|
+
loads readiness policies from that same protected publisher config for its
|
|
32
|
+
input fingerprints. Do not duplicate policies in the scanner config. Remote/container paths are unsupported. If the
|
|
33
|
+
agent cannot invoke the helper, stop; do not fall back to direct writes.
|
|
34
|
+
3. Cloud conversations are unsupported: bundling does not provision their files,
|
|
35
|
+
and the dispatcher cannot retrieve structured results from them.
|
|
36
|
+
|
|
37
|
+
Publisher config uses existing `repos` and optional `triage_readiness_policies`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"repos": ["owner/repo"],
|
|
42
|
+
"triage_readiness_policies": {
|
|
43
|
+
"owner/repo": {
|
|
44
|
+
"mode": "authorized-writers",
|
|
45
|
+
"files": {".github/workflows/issue-readiness-check.yml": "<audited GitHub blob SHA>"}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Audit current policy and pin every relevant file to its actual 40-character
|
|
52
|
+
GitHub blob SHA (the file-content identifier). Only writer-only policies are
|
|
53
|
+
supported: the helper checks current SHAs and live `write`/`maintain`/`admin`
|
|
54
|
+
permission. Never select this mode for a policy that checks issue content.
|
|
55
|
+
Unknown, changed, or unsupported policies block new readiness grants, preserve
|
|
56
|
+
existing labels, and still allow criteria publication. No repository checker runs.
|
|
57
|
+
|
|
58
|
+
Precreate `ready-for-dev` and `priority:low/medium/high` labels. Use the same
|
|
59
|
+
GitHub account for scanner and publisher. Its fine-grained PAT needs selected-repo
|
|
60
|
+
Issues read/write, Contents read, and permission lookup access. Never put token
|
|
61
|
+
values in prompts or automation definitions.
|
|
62
|
+
|
|
63
|
+
## Races and retries
|
|
64
|
+
|
|
65
|
+
- Checks and writes are not atomic: people or other automations can change inputs
|
|
66
|
+
between GET and PATCH. There is no shared lock or all-or-nothing update across
|
|
67
|
+
issue bodies, comments, and labels. Changed inputs stop publication; the next
|
|
68
|
+
scan recomputes the recommendation.
|
|
69
|
+
- The input fingerprint includes human readiness-label changes from the issue
|
|
70
|
+
timeline, but excludes the publisher's and GitHub Actions' label changes.
|
|
71
|
+
- A completion receipt is saved only after publication finishes. Partial writes
|
|
72
|
+
change the guarded-mode delivery key, allowing retries. If an unchanged failure
|
|
73
|
+
is marked finished by the dispatcher, manually retry the same command.
|
|
74
|
+
- Configured policy changes affect the fingerprint. Upstream policy changes alone
|
|
75
|
+
do not automatically retriage unchanged issues with managed markers.
|
|
@@ -9,49 +9,29 @@ triggers:
|
|
|
9
9
|
|
|
10
10
|
Prioritize open issues and establish acceptance criteria before marking them ready for development.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
applicable boundary: failures and edge cases, compatibility or migration,
|
|
39
|
-
lifecycle and cleanup, permissions and secrets, user-facing documentation, and
|
|
40
|
-
realistic automated or live validation. Avoid subjective criteria and avoid
|
|
41
|
-
prescribing an implementation unless repository policy requires one mechanism.
|
|
42
|
-
|
|
43
|
-
For an issue that is ready, preserve the author's description and maintain one
|
|
44
|
-
clearly marked OpenHands AI section at the end of the issue body. Put the triage
|
|
45
|
-
scope, any missing repository-required readiness sections, and testable
|
|
46
|
-
acceptance criteria there. Replace only that marked section on later runs and do
|
|
47
|
-
not also post a triage comment. Add `ready-for-dev` only after the final criteria
|
|
48
|
-
pass this standard.
|
|
49
|
-
|
|
50
|
-
If the design is still unclear, remove any stale managed body section, leave the
|
|
51
|
-
human-owned body unchanged, and post the minimum focused questions in one marked
|
|
52
|
-
comment below the human discussion. Withhold `ready-for-dev`. Preserve unrelated
|
|
53
|
-
labels and never edit or delete human-authored text.
|
|
54
|
-
Do not implement code or accept pull requests.
|
|
55
|
-
|
|
56
|
-
Each scheduled run submits every changed eligible issue. A failure on one issue is
|
|
57
|
-
reported and does not prevent the remaining issues from being submitted.
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Keep triage separate from implementation and review. Follow [README.md](README.md)
|
|
15
|
+
for packaging, credentials, publisher setup, and limits. Never put token values
|
|
16
|
+
in the automation definition or prompt. Legacy agent publication is the default.
|
|
17
|
+
|
|
18
|
+
## Triage instructions
|
|
19
|
+
|
|
20
|
+
- Honor explicit dependencies. Read applicable root/local `AGENTS.md`,
|
|
21
|
+
`.agents/skills/custom-codereview-guide.md` if present, human discussion, and
|
|
22
|
+
adjacent code. Identify affected versions and the owning component (Canvas,
|
|
23
|
+
SDK, automation, or extensions).
|
|
24
|
+
- Follow current human decisions over stale generated criteria. Do not repeat
|
|
25
|
+
answered questions. Keep scope bounded, name non-goals, and ask only unresolved
|
|
26
|
+
material product/design questions.
|
|
27
|
+
- Define observable completion, not entry requirements. Do not require tests for
|
|
28
|
+
unwritten code, PR artifacts, or images/videos for every issue. Default priority
|
|
29
|
+
to low; medium/high need recorded real user pain, not speculation or synthetic
|
|
30
|
+
tests.
|
|
31
|
+
- Return structured recommendations. If a publisher is configured, use it; never
|
|
32
|
+
bypass it or its changed-input checks. Legacy direct mode follows the same rules
|
|
33
|
+
without enforcement.
|
|
34
|
+
- Preserve human text and unrelated labels. Never run repository checkers with
|
|
35
|
+
credentials. Use current repository policy audited by the deployment owner, not
|
|
36
|
+
historical checker comments, to decide readiness.
|
|
37
|
+
- Do not implement code or accept pull requests.
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from urllib.error import HTTPError
|
|
8
|
+
from urllib.parse import urlencode
|
|
7
9
|
from urllib.request import Request, urlopen
|
|
8
10
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
9
11
|
|
|
@@ -113,6 +115,10 @@ class CloudConversations:
|
|
|
113
115
|
},
|
|
114
116
|
)
|
|
115
117
|
|
|
118
|
+
def agent_profiles(self) -> list[dict]:
|
|
119
|
+
listing = self._request("GET", "/api/agent-profiles")
|
|
120
|
+
return (listing or {}).get("profiles") or []
|
|
121
|
+
|
|
116
122
|
def run(self, conversation: dict) -> None:
|
|
117
123
|
"""Start the next turn of an idle conversation on its own Agent Server."""
|
|
118
124
|
try:
|
|
@@ -152,6 +158,17 @@ def _user_message(text: str) -> dict:
|
|
|
152
158
|
return {"role": "user", "content": [{"type": "text", "text": text}]}
|
|
153
159
|
|
|
154
160
|
|
|
161
|
+
def _render(prompt, conversation_id) -> str:
|
|
162
|
+
"""The text of `prompt` for the conversation it is delivered to.
|
|
163
|
+
|
|
164
|
+
A caller that must name the receiving conversation in its own prompt (a
|
|
165
|
+
marker the deterministic side later looks for, say) passes a function of
|
|
166
|
+
the conversation id, because only delivery decides which conversation that
|
|
167
|
+
is.
|
|
168
|
+
"""
|
|
169
|
+
return prompt(str(conversation_id)) if callable(prompt) else prompt
|
|
170
|
+
|
|
171
|
+
|
|
155
172
|
class AgentConversationDispatcher:
|
|
156
173
|
"""Deliver one revision at a time to a stable conversation for each subject."""
|
|
157
174
|
|
|
@@ -189,7 +206,11 @@ class AgentConversationDispatcher:
|
|
|
189
206
|
return self._workspace.__exit__(*args)
|
|
190
207
|
|
|
191
208
|
def deliver(
|
|
192
|
-
self,
|
|
209
|
+
self,
|
|
210
|
+
subject: str,
|
|
211
|
+
delivery: str,
|
|
212
|
+
prompt: str | Callable[[str], str],
|
|
213
|
+
head: str = "",
|
|
193
214
|
) -> dict[str, str]:
|
|
194
215
|
"""Deliver one revision of `subject`, deduping a repeated head.
|
|
195
216
|
|
|
@@ -202,6 +223,7 @@ class AgentConversationDispatcher:
|
|
|
202
223
|
A conversation already running for this head is reported `in_progress`
|
|
203
224
|
and left alone, whatever the new delivery says. Callers with no revision
|
|
204
225
|
identity leave `head` empty and keep the delivery-key-only behavior.
|
|
226
|
+
`prompt` may be a function of the receiving conversation's id.
|
|
205
227
|
"""
|
|
206
228
|
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
207
229
|
state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
|
|
@@ -252,6 +274,67 @@ class AgentConversationDispatcher:
|
|
|
252
274
|
"conversation_id": str(conversation_id),
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
def subject_conversation(self, subject: str) -> str | None:
|
|
278
|
+
"""The conversation this automation currently delivers `subject` to."""
|
|
279
|
+
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
280
|
+
record = _kv_request(f"{_CONVERSATION_KEY_PREFIX}{conversation_id}", "GET")
|
|
281
|
+
return (record or {}).get("conversation_id") or None
|
|
282
|
+
|
|
283
|
+
def llm_provenance(self, conversation_id: str) -> tuple[str, str] | None:
|
|
284
|
+
"""The LLM profile name and model a conversation runs, or None if it is gone.
|
|
285
|
+
|
|
286
|
+
Both are read back from the conversation, never from the automation's
|
|
287
|
+
current settings: the server resolves the agent profile when it starts
|
|
288
|
+
the conversation, and a reused conversation keeps that LLM after the
|
|
289
|
+
profile is edited. The model is the conversation's own. The LLM profile
|
|
290
|
+
name comes from the launching agent profile only while that profile is
|
|
291
|
+
still at its launch revision, and is `unknown` otherwise.
|
|
292
|
+
"""
|
|
293
|
+
if self._cloud:
|
|
294
|
+
conversation = self._cloud.get(conversation_id)
|
|
295
|
+
if conversation is None:
|
|
296
|
+
return None
|
|
297
|
+
model = conversation.get("llm_model")
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
300
|
+
conversation = self._agent_server_get(
|
|
301
|
+
f"/api/conversations/{conversation_id}"
|
|
302
|
+
)
|
|
303
|
+
except HTTPError as exc:
|
|
304
|
+
if exc.code == 404:
|
|
305
|
+
return None
|
|
306
|
+
raise
|
|
307
|
+
agent = conversation.get("agent") or {}
|
|
308
|
+
model = conversation.get("current_model_id")
|
|
309
|
+
if model is None and agent.get("kind") != "ACPAgent":
|
|
310
|
+
model = (agent.get("llm") or {}).get("model")
|
|
311
|
+
launched = conversation.get("launched_agent_profile") or {}
|
|
312
|
+
profile = "unknown"
|
|
313
|
+
if launched.get("agent_profile_id"):
|
|
314
|
+
try:
|
|
315
|
+
if self._cloud:
|
|
316
|
+
profiles = self._cloud.agent_profiles()
|
|
317
|
+
else:
|
|
318
|
+
listing = self._agent_server_get("/api/agent-profiles")
|
|
319
|
+
profiles = (listing or {}).get("profiles") or []
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - the model is still known
|
|
321
|
+
print(f"Could not read agent profiles: {type(exc).__name__}: {exc}")
|
|
322
|
+
profiles = []
|
|
323
|
+
for item in profiles:
|
|
324
|
+
if str(item.get("id")) == str(launched["agent_profile_id"]):
|
|
325
|
+
if item.get("revision") == launched.get("revision"):
|
|
326
|
+
profile = item.get("llm_profile_ref") or "unknown"
|
|
327
|
+
break
|
|
328
|
+
return profile, model or "unknown"
|
|
329
|
+
|
|
330
|
+
def _agent_server_get(self, path: str) -> dict:
|
|
331
|
+
request = Request(
|
|
332
|
+
f"{self.agent_url.rstrip('/')}{path}",
|
|
333
|
+
headers={"X-Session-API-Key": self.api_key},
|
|
334
|
+
)
|
|
335
|
+
with urlopen(request, timeout=90) as response:
|
|
336
|
+
return json.load(response)
|
|
337
|
+
|
|
255
338
|
def _deliver_cloud(
|
|
256
339
|
self,
|
|
257
340
|
record,
|
|
@@ -287,7 +370,9 @@ class AgentConversationDispatcher:
|
|
|
287
370
|
|
|
288
371
|
if conversation is None:
|
|
289
372
|
new_id = uuid4() if record else conversation_id
|
|
290
|
-
self._cloud.start(
|
|
373
|
+
self._cloud.start(
|
|
374
|
+
new_id, self.profile_id, subject, _render(prompt, new_id)
|
|
375
|
+
)
|
|
291
376
|
return "created", new_id
|
|
292
377
|
|
|
293
378
|
status = conversation.get("execution_status")
|
|
@@ -303,10 +388,10 @@ class AgentConversationDispatcher:
|
|
|
303
388
|
# The API reports an execution status only while the sandbox
|
|
304
389
|
# runs, so an errored conversation whose sandbox was already
|
|
305
390
|
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
-
self._cloud.send(conversation, prompt)
|
|
391
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
307
392
|
return "retried", current_id
|
|
308
393
|
return "deduplicated", current_id
|
|
309
|
-
self._cloud.send(conversation, prompt)
|
|
394
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
310
395
|
return "resumed", current_id
|
|
311
396
|
|
|
312
397
|
def _deliver_local(
|
|
@@ -326,12 +411,13 @@ class AgentConversationDispatcher:
|
|
|
326
411
|
conversation = RemoteConversation.create(
|
|
327
412
|
self._workspace,
|
|
328
413
|
StartConversationRequest(
|
|
329
|
-
workspace=LocalWorkspace(working_dir=
|
|
414
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
330
415
|
conversation_id=conversation_id,
|
|
331
416
|
agent_profile_id=self.profile_id,
|
|
332
417
|
secrets=self._secrets,
|
|
333
418
|
initial_message=SendMessageRequest(
|
|
334
|
-
content=[TextContent(text=prompt)],
|
|
419
|
+
content=[TextContent(text=_render(prompt, conversation_id))],
|
|
420
|
+
run=True,
|
|
335
421
|
),
|
|
336
422
|
),
|
|
337
423
|
visualizer=None,
|
|
@@ -365,7 +451,7 @@ class AgentConversationDispatcher:
|
|
|
365
451
|
# the work finished, so send the revision again rather
|
|
366
452
|
# than strand it.
|
|
367
453
|
conversation.update_secrets(self._secrets)
|
|
368
|
-
conversation.send_message(prompt)
|
|
454
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
369
455
|
conversation.run(blocking=False)
|
|
370
456
|
disposition = "retried"
|
|
371
457
|
else:
|
|
@@ -375,8 +461,138 @@ class AgentConversationDispatcher:
|
|
|
375
461
|
# work on the same subject: reuse the conversation and send
|
|
376
462
|
# the revision as its next turn.
|
|
377
463
|
conversation.update_secrets(self._secrets)
|
|
378
|
-
conversation.send_message(prompt)
|
|
464
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
379
465
|
conversation.run(blocking=False)
|
|
380
466
|
finally:
|
|
381
467
|
conversation.close()
|
|
382
468
|
return disposition
|
|
469
|
+
|
|
470
|
+
def review_snapshot(self, conversation_id):
|
|
471
|
+
if self._cloud:
|
|
472
|
+
conversation = self._cloud.get(conversation_id)
|
|
473
|
+
if conversation is None:
|
|
474
|
+
return "missing", ""
|
|
475
|
+
status = conversation.get("execution_status", "unknown")
|
|
476
|
+
if status != "finished":
|
|
477
|
+
return status, ""
|
|
478
|
+
result = self._cloud._request(
|
|
479
|
+
"GET",
|
|
480
|
+
f"{conversation['conversation_url']}/agent_final_response",
|
|
481
|
+
headers={"X-Session-API-Key": conversation["session_api_key"]},
|
|
482
|
+
)
|
|
483
|
+
return status, result.get("response", "")
|
|
484
|
+
headers = {"X-Session-API-Key": self.api_key}
|
|
485
|
+
base = f"{self.agent_url.rstrip('/')}/api/conversations/{conversation_id}"
|
|
486
|
+
try:
|
|
487
|
+
with urlopen(Request(base, headers=headers), timeout=90) as response:
|
|
488
|
+
status = json.load(response).get("execution_status", "unknown")
|
|
489
|
+
except HTTPError as exc:
|
|
490
|
+
if exc.code == 404:
|
|
491
|
+
return "missing", ""
|
|
492
|
+
raise
|
|
493
|
+
if status != "finished":
|
|
494
|
+
return status, ""
|
|
495
|
+
with urlopen(
|
|
496
|
+
Request(base + "/agent_final_response", headers=headers), timeout=90
|
|
497
|
+
) as response:
|
|
498
|
+
return status, json.load(response).get("response", "")
|
|
499
|
+
|
|
500
|
+
def start_review_work(self, conversation_id, subject, prompt):
|
|
501
|
+
if self._cloud:
|
|
502
|
+
self._cloud.start(UUID(conversation_id), self.profile_id, subject, prompt)
|
|
503
|
+
return
|
|
504
|
+
if self._workspace is None:
|
|
505
|
+
raise RuntimeError("Dispatcher must be used as a context")
|
|
506
|
+
conversation = RemoteConversation.create(
|
|
507
|
+
self._workspace,
|
|
508
|
+
StartConversationRequest(
|
|
509
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
510
|
+
conversation_id=UUID(conversation_id),
|
|
511
|
+
agent_profile_id=self.profile_id,
|
|
512
|
+
secrets=self._secrets,
|
|
513
|
+
initial_message=SendMessageRequest(
|
|
514
|
+
content=[TextContent(text=prompt)], run=True
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
visualizer=None,
|
|
518
|
+
)
|
|
519
|
+
conversation.close()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class ReviewCoordination:
|
|
523
|
+
"""CAS against the automation service's shared, user-authorized KV namespace."""
|
|
524
|
+
|
|
525
|
+
def __init__(self):
|
|
526
|
+
namespace = os.environ.get("REVIEW_COORDINATION_AUTOMATION_ID")
|
|
527
|
+
key = os.environ.get("REVIEW_COORDINATION_API_KEY")
|
|
528
|
+
if not namespace or not key:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
"Structured reviews require shared coordination namespace and API key"
|
|
531
|
+
)
|
|
532
|
+
self.query = urlencode({"automation_id": str(UUID(namespace))})
|
|
533
|
+
self.base = os.environ["AUTOMATION_API_URL"].rstrip("/")
|
|
534
|
+
self.headers = {"X-Session-API-Key": key, "Content-Type": "application/json"}
|
|
535
|
+
|
|
536
|
+
def request(self, key, method="GET", value=None, condition=""):
|
|
537
|
+
request = Request(
|
|
538
|
+
f"{self.base}/v1/kv/{key}?{self.query}&{condition}",
|
|
539
|
+
data=None if value is None else json.dumps(value).encode(),
|
|
540
|
+
headers=self.headers,
|
|
541
|
+
method=method,
|
|
542
|
+
)
|
|
543
|
+
try:
|
|
544
|
+
with urlopen(request, timeout=90) as response:
|
|
545
|
+
return json.load(response)
|
|
546
|
+
except HTTPError as exc:
|
|
547
|
+
if method == "GET" and exc.code == 404:
|
|
548
|
+
return None
|
|
549
|
+
raise
|
|
550
|
+
|
|
551
|
+
def claim(self, subject, *, existing_only=False):
|
|
552
|
+
key = "review-work-" + str(uuid5(NAMESPACE_URL, subject))
|
|
553
|
+
for _ in range(5):
|
|
554
|
+
current = self.request(key, condition="meta=true")
|
|
555
|
+
if current is None and existing_only:
|
|
556
|
+
return None
|
|
557
|
+
value = (current or {}).get("value") or {}
|
|
558
|
+
if value.get("lease_until", 0) > time.time():
|
|
559
|
+
return None
|
|
560
|
+
value = {**value, "owner": str(uuid4()), "lease_until": time.time() + 180}
|
|
561
|
+
condition = f"if_version={current['version']}" if current else "nx=true"
|
|
562
|
+
try:
|
|
563
|
+
self.request(key, "PUT", value, condition)
|
|
564
|
+
return ReviewLease(self, key, value)
|
|
565
|
+
except HTTPError as exc:
|
|
566
|
+
if exc.code != 409:
|
|
567
|
+
raise
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class ReviewLease:
|
|
572
|
+
def __init__(self, store, key, value):
|
|
573
|
+
self.store, self.key, self.value = store, key, value
|
|
574
|
+
self.owner = value["owner"]
|
|
575
|
+
|
|
576
|
+
def save(self, **changes):
|
|
577
|
+
for _ in range(5):
|
|
578
|
+
current = self.store.request(self.key, condition="meta=true")
|
|
579
|
+
value = current["value"]
|
|
580
|
+
if (
|
|
581
|
+
value.get("owner") != self.owner
|
|
582
|
+
or value.get("lease_until", 0) <= time.time()
|
|
583
|
+
):
|
|
584
|
+
raise RuntimeError("Review lease lost; refusing side effects")
|
|
585
|
+
updated = {**self.value, **changes}
|
|
586
|
+
try:
|
|
587
|
+
self.store.request(
|
|
588
|
+
self.key, "PUT", updated, f"if_version={current['version']}"
|
|
589
|
+
)
|
|
590
|
+
self.value = updated
|
|
591
|
+
return
|
|
592
|
+
except HTTPError as exc:
|
|
593
|
+
if exc.code != 409:
|
|
594
|
+
raise
|
|
595
|
+
raise RuntimeError("Review coordination contention; retry next scan")
|
|
596
|
+
|
|
597
|
+
def release(self):
|
|
598
|
+
self.save(lease_until=0)
|