@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
|
@@ -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
|
|
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.
|
|
190
|
-
|
|
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**,
|
|
454
|
-
|
|
455
|
-
GitHub state before deciding
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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,
|
|
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)
|