@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.
Files changed (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. package/tests/vscode-app.test.mjs +8 -2
@@ -66,9 +66,30 @@ This skill is activated by:
66
66
  assertions, generated mockups, and reconstructed captures cannot substitute.
67
67
  Missing evidence yields a COMMENT review that names the gap, so no approval
68
68
  and no maintainer handoff
69
+ - Ends each published review, fallback comment, and error comment with the LLM
70
+ profile and model of the conversation that wrote it:
71
+ `LLM profile: <profile> · Model: <model>`. The deterministic script appends
72
+ the footer, never the agent. With default agent publication, it updates only
73
+ a review whose hidden run marker names that conversation, so automations that
74
+ share a bot account cannot relabel each other's reviews. Opt-in worker
75
+ publication formats the claimed conversation's footer before its durable
76
+ publication intent is saved. The catalog worker records each review's
77
+ server-derived metadata or an explicit `unknown` fallback in its
78
+ automation-scoped KV store before updating the review; only that record,
79
+ never an agent-written footer, establishes
80
+ attribution. It keeps those values if the agent profile changes later; a
81
+ failed lookup is logged and never holds up the verdict or maintainer handoff.
82
+ Failed lookups and uncertain KV writes are reconciled against the saved
83
+ metadata; failure on one review does not prevent the worker from correcting
84
+ other owned reviews.
85
+ The first tuple, including an `unknown` fallback, is retained for that review.
86
+ If KV cannot establish or validate a tuple, the edit is skipped and logged.
87
+ The manual upload flow retries a failed lookup or edit on its next poll
69
88
  - Posts acknowledgement comments with AI disclosure
70
89
  - Configurable review tone and polling schedule
71
- - Optional human handoff after an exact-head approval. The scanner ranks the
90
+ - Optional human handoff after an exact-head approval or a maintainer-decision
91
+ scope stop, only when a same-repository closing issue is successfully fetched
92
+ and labeled `priority:medium` or `priority:high`. The scanner ranks the
72
93
  configured maintainers by recent commits to changed paths, then by their open
73
94
  GitHub review-request count, and requests one without merging the PR. Use at
74
95
  least two repository collaborators so a maintainer can author a PR without
@@ -76,12 +97,32 @@ This skill is activated by:
76
97
 
77
98
  ## Prerequisites
78
99
 
100
+ The catalog worker starts each review conversation from the automation's
101
+ agent profile, which the server resolves. Its footer reads the model back from
102
+ that conversation, so a reused conversation reports the LLM it actually runs,
103
+ and takes the LLM profile name from the agent profile while it is still at its
104
+ launch revision (`unknown` once the profile has been edited since).
105
+ For local ACP profiles, the model is the server's reported session model;
106
+ without that metadata it is `unknown`, since the configured override and
107
+ `acp-managed` placeholder do not establish which model produced the review.
108
+
109
+ The manual upload flow reports `default` and the concrete model from the
110
+ server settings it already uses to start the conversation. That label describes
111
+ the settings source; it does not infer a named profile from the active-profile
112
+ pointer.
113
+
79
114
  Set `GITHUB_PERSONAL_ACCESS_TOKEN` in OpenHands Settings -> Secrets. The token
80
115
  must be able to read the repositories and their contents, read issue events,
81
116
  write issue comments, and **write pull request reviews** — the review is
82
117
  published and the optional human reviewer is requested through the pull request
83
118
  API, so read-only pull request access is not enough.
84
119
 
120
+ On OpenHands Cloud and Enterprise, the Agent Canvas catalog template needs no
121
+ saved secret when GitHub is connected under Settings -> Integrations: a secret
122
+ saved under the configured name is used if present, otherwise the connected
123
+ account's token, and the review is published by that account. Set the
124
+ requested reviewer to that account's login.
125
+
85
126
  ## Quick Start
86
127
 
87
128
  Ask OpenHands for either trigger mode:
@@ -96,3 +137,70 @@ can instead use a configured label and re-apply it for another review.
96
137
  ## See Also
97
138
 
98
139
  - [SKILL.md](SKILL.md) - Full setup workflow reference
140
+
141
+ ## Opt-in worker publication
142
+
143
+ Legacy agent publication is the default. With `structured_publication: true`,
144
+ `worker.py` publishes the agent's JSON result, validating `head_sha`,
145
+ `code_assessment` (`clean`, `material_findings`, `inconclusive`),
146
+ `merge_readiness` (`ready`, `blocked`, `unknown`), summary, findings, and evidence.
147
+ It chooses the event and footer. Only clean, ready results approve;
148
+ clean self-reviews use COMMENT. Missing merge requirements are not code defects.
149
+ **This mode puts findings in the body, not inline threads.**
150
+
151
+ The publisher appends the claimed conversation's profile/model metadata before
152
+ saving its publication intent. Receipt recovery uses that saved body, so a later
153
+ profile edit cannot relabel the review. An unavailable metadata read is logged
154
+ and leaves publication and handoff available.
155
+
156
+ ### Operator prerequisites
157
+
158
+ Keep disabled until you verify these prerequisites; setup is not automatic.
159
+
160
+ - Bundle `publication.py`. Run periodic scans alongside event intake. Both recover
161
+ finished conversations before selecting new work.
162
+ - Set `REVIEW_COORDINATION_AUTOMATION_ID` to the same namespace for both automations,
163
+ plus `REVIEW_COORDINATION_API_KEY` (a user-authorized automation API key) and
164
+ `AUTOMATION_API_URL`. The API must support `automation_id` user auth, metadata
165
+ versions, create-if-absent, and writes conditional on the stored version. The
166
+ run's automation-scoped KV JWT (key-value storage token) is insufficient.
167
+ Unsupported APIs stop publication.
168
+ - Set `review_read_token_secret` and `review_profile_read_only: true` only after
169
+ independently verifying profile/sandbox isolation. This flag **does not enforce
170
+ isolation**. Agents must have read-only GitHub access, no publisher/coordination
171
+ credentials, and no write-capable MCP tools. The separate read token needs
172
+ pull-request, repository-content, and linked-issue read access; the worker
173
+ also needs issue read access to check handoff priority. Never add the coordination key to
174
+ the agent profile.
175
+ - Verify Agent Server exposes execution status and
176
+ `/api/conversations/{id}/agent_final_response` returning `response`. Only
177
+ `finished` results are used; no workspace-file transport is supported.
178
+ - Set `scan_window: true` for the VM's rotating discovery window of ten unrequested
179
+ PRs. Explicit candidates bypass this window. Automatic maintainer handoff for
180
+ both approvals and scope stops requires a successfully fetched same-repository
181
+ closing issue labeled `priority:medium` or `priority:high`. Missing links,
182
+ missing priority labels, low/normal priorities, unresolved references, and PR
183
+ references do not qualify. One qualifying issue permits handoff even when
184
+ other references are low-priority or missing. Non-404 lookup errors propagate
185
+ without requesting a review. Existing manual review requests are unchanged.
186
+
187
+ ### Recovery and limits
188
+
189
+ Time-limited locks (leases) coordinate work for each repository/PR/reviewer.
190
+ The head, latest request and label events, and latest non-bot clarification
191
+ identify a work version (generation) across triggers.
192
+
193
+ Intent is recorded before POST. Lost responses/receipts trigger a search by
194
+ exact marker, account, and head. **An empty search never
195
+ permits retrying an uncertain POST.** Completed/rejected generations stay recorded.
196
+ Missing/failed starts are held for inspection, including records left in
197
+ `starting` after a crash. Inspect the recorded conversation ID and server status
198
+ before repairing the record; do not blindly restart a possibly running agent.
199
+ Invalid/stale results wait for new work. Handoff intent is retained: crashes during handoff require operator
200
+ reconciliation. Inspect shared `review-work-*` records before manual repair.
201
+ Never delete uncertain POST intents just because a review is not yet visible.
202
+
203
+ Publication is **not guaranteed exactly once**. GitHub cannot reject a review POST
204
+ based on an expired lease or require the head to remain unchanged. The worker pins
205
+ `commit_id` and rechecks the head, but a push can still race the POST. JSON validation
206
+ cannot prove that written evidence claims are true.
@@ -186,8 +186,12 @@ move repositories, close, or receive a maintainer decision, and ends with the
186
186
  `🛑 MAINTAINER DECISION REQUIRED` verdict. That outcome is **neither an approval
187
187
  nor a change request**: it does not approve or merge the PR. The completion
188
188
  handler recognizes the verdict and requests one configured maintainer through the
189
- same handoff used after an approval. An in-scope change continues the existing
190
- review unchanged.
189
+ same handoff used after an approval. Both paths require a successfully fetched
190
+ same-repository closing issue labeled `priority:medium` or `priority:high`; a
191
+ missing link or priority label, low/normal priority, unresolved reference, or PR
192
+ reference does not authorize handoff. One qualifying issue is sufficient even
193
+ with other low-priority or missing references. Existing manual review requests
194
+ are unchanged. An in-scope change continues the existing review unchanged.
191
195
 
192
196
  Before approving a change to user-visible UI behavior, the prompt requires live
193
197
  evidence from a real running application when the repository's guidance demands
@@ -201,6 +205,23 @@ deterministic maintainer handoff does not fire. Non-UI changes keep the existing
201
205
  requirement of the real command and its observed output, with tests insufficient
202
206
  as sole proof.
203
207
 
208
+ Every review ends with the LLM profile and model of the conversation that wrote
209
+ it, after the verdict: `LLM profile: <profile> · Model: <model>`. The agent does
210
+ not write this footer. The prompt gives it a hidden marker naming its own
211
+ conversation, `<!-- openhands-review-run: <conversation-id> -->`, to copy under
212
+ the disclosure, and the deterministic script appends the footer to the reviews
213
+ that carry exactly that marker and no other. Login, head, and submission time
214
+ cannot tell two automations that share a bot account apart, so a review without
215
+ the marker, with another run's marker, or with several markers is never edited.
216
+ The catalog worker reads the values back from the marked conversation: the
217
+ model it runs, and the LLM profile its agent profile referenced at launch
218
+ (`unknown` once that agent profile has been edited). It never substitutes the
219
+ scanner's own settings, and a reused conversation keeps reporting the LLM it
220
+ was launched with. A review is stamped once, so a later agent profile edit
221
+ cannot rewrite its footer, and a provenance failure is logged without holding
222
+ up the verdict or the maintainer handoff. The completion handler reads the
223
+ verdict above the footer.
224
+
204
225
  The script prepares each review's workspace before the agent starts: the pull
205
226
  request's head commit is downloaded as a tarball and extracted to a directory of
206
227
  its own, which becomes the conversation's working directory. The agent is told
@@ -233,6 +254,13 @@ requesting the handoff.
233
254
 
234
255
  When several repositories are monitored, the token must cover all of them.
235
256
 
257
+ On OpenHands Cloud and Enterprise, the Agent Canvas catalog template needs no
258
+ saved secret when GitHub is connected under Settings -> Integrations: a secret
259
+ saved under the configured name is used if present, otherwise the connected
260
+ account's token, and the review is published by that account. The requested
261
+ reviewer should be that account's login. The steps below apply to the local
262
+ setup through this skill.
263
+
236
264
  Check with:
237
265
  ```bash
238
266
  curl -s https://api.github.com/user \
@@ -449,10 +477,13 @@ For each repository:
449
477
  `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The
450
478
  archive is checked as it is unpacked: a single root, no absolute or `..`
451
479
  paths, and symlinks skipped rather than materialised.
480
+ - Records `default` and the concrete model from the server settings already
481
+ used to start the conversation, for the review footer.
452
482
  - Starts an OpenHands conversation **whose working directory is that
453
- checkout**, with a review prompt carrying PR metadata, the exact head SHA,
454
- label event details, and the requirement to re-fetch the current mutable
455
- GitHub state before deciding a verdict.
483
+ checkout**, under an id chosen before it starts, with a review prompt
484
+ carrying PR metadata, the exact head SHA, label event details, the
485
+ requirement to re-fetch the current mutable GitHub state before deciding
486
+ a verdict, and that conversation's review marker.
456
487
  - Posts an acknowledgement comment with the label event, head SHA, and
457
488
  conversation link.
458
489
  - Records the review in state with `status: "active"` and the checkout path.
@@ -463,9 +494,12 @@ For each repository:
463
494
  - Suppresses stale results if the PR head SHA changed after the review was
464
495
  queued.
465
496
  - When the conversation reaches `idle`, `finished`, `error`, or `stuck`,
466
- asks GitHub whether a review by the token's own user exists for that head
467
- SHA. If it does, the review is complete. If it does not, the agent's final
468
- response is posted as a comment so the work is not lost.
497
+ verifies a submitted review by the token's own user at that head SHA and
498
+ appends the recorded LLM profile and model to each review carrying this
499
+ conversation's marker before marking the review complete. Reviews without
500
+ that marker are confirmed but never edited. If no review exists, the
501
+ agent's final response is posted as a comment with provenance. Failed
502
+ verification or publication is retried on the next poll.
469
503
  - Abandons a conversation that has not reached a terminal status within two
470
504
  hours, so its checkout can be reclaimed.
471
505
  6. Removes the checkout of every finished review, but only after confirming the
@@ -81,6 +81,8 @@ Value: **ReviewRecord**
81
81
  "status": "active",
82
82
  "conversation_id": "550e8400-e29b-41d4-a716-446655440000",
83
83
  "workspace_dir": "/workspace/repositories/owner__repo/pr-42-0123456789ab",
84
+ "llm_profile": "default",
85
+ "llm_model": "openai/review-model",
84
86
  "last_activity": 1717200000.0
85
87
  }
86
88
  ```
@@ -97,6 +99,12 @@ Value: **ReviewRecord**
97
99
 
98
100
  When a review becomes stale, `stale_reason` records the old and new head SHAs.
99
101
  When a review closes after posting, `completed_at` records the completion time.
102
+ `llm_profile` labels the server's default settings as `default`, and `llm_model`
103
+ records the concrete model already used to start the conversation. They become
104
+ the footer of each review
105
+ whose hidden `<!-- openhands-review-run: {conversation_id} -->` marker names
106
+ this record's `conversation_id`; no other review is edited. A record written
107
+ before these fields existed reports both as `unknown`.
100
108
  When a review expires, `expired_after` records how many seconds it had been
101
109
  waiting.
102
110
 
@@ -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, subject: str, delivery: str, prompt: str, head: str = ""
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(new_id, self.profile_id, subject, prompt)
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="/workspace"),
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)], run=True
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)