@openhands/extensions 0.29.0 → 0.31.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 +47 -5
- package/.claude-plugin/marketplace.json +20 -5
- package/.codex-plugin/marketplace.json +20 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/pr-artifacts.yml +4 -83
- package/.github/workflows/tests.yml +14 -1
- package/.plugin/marketplace.json +20 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +4 -3
- package/README.md +6 -5
- 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 +10 -8
- 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 +7 -6
- 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 +20 -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 +109 -1
- package/skills/github-pr-reviewer/SKILL.md +42 -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/github-stale-ci-pr-closer/scripts/worker.py +8 -2
- 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 +18 -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 +12 -3
- package/skills/slack-channel-monitor/SKILL.md +35 -20
- package/skills/slack-channel-monitor/references/windows.md +6 -2
- package/skills/slack-channel-monitor/scripts/main.py +189 -12
- package/skills/software-factory/.claude-plugin/plugin.json +19 -0
- package/skills/software-factory/.codex-plugin/plugin.json +19 -0
- package/skills/software-factory/.plugin/plugin.json +19 -0
- package/skills/software-factory/README.md +64 -0
- package/skills/software-factory/SKILL.md +229 -0
- package/skills/software-factory/commands/software-factory.md +8 -0
- package/skills/software-factory/scripts/factory.py +630 -0
- 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_stale_ci_pr_closer.py +52 -1
- 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_software_factory_script.py +231 -0
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -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)
|
|
@@ -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)
|