@dynamicagents/plugins 0.11.0 → 0.12.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/README.md +12 -13
- package/dist/arc-agi/analysis.d.ts +16 -30
- package/dist/arc-agi/analysis.js +20 -47
- package/dist/arc-agi/client.d.ts +3 -2
- package/dist/arc-agi/game-tools.d.ts +5 -6
- package/dist/arc-agi/index.d.ts +6 -8
- package/dist/arc-agi/index.js +3 -3
- package/dist/arc-agi/main-agent.d.ts +5 -8
- package/dist/arc-agi/main-agent.js +5 -8
- package/dist/arc-agi/recipe.d.ts +17 -20
- package/dist/arc-agi/recipe.js +17 -20
- package/dist/arc-agi/soul.d.ts +16 -27
- package/dist/arc-agi/soul.js +16 -27
- package/dist/arc-agi/store.d.ts +4 -5
- package/dist/arc-agi/store.js +4 -5
- package/dist/arc-agi/tools.d.ts +1 -1
- package/dist/arc-agi/tools.js +26 -19
- package/dist/arc-agi/types.d.ts +6 -7
- package/dist/claude-code/config.d.ts +88 -11
- package/dist/claude-code/config.js +11 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +7 -13
- package/dist/claude-code/egress.js +9 -10
- package/dist/claude-code/events.d.ts +47 -0
- package/dist/claude-code/events.js +96 -10
- package/dist/claude-code/index.d.ts +31 -13
- package/dist/claude-code/index.js +62 -15
- package/dist/claude-code/run.d.ts +135 -41
- package/dist/claude-code/run.js +366 -61
- package/dist/computer/advisory.d.ts +31 -18
- package/dist/computer/advisory.js +30 -5
- package/dist/computer/context.d.ts +23 -0
- package/dist/computer/context.js +225 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +39 -0
- package/dist/computer/gate.d.ts +8 -8
- package/dist/computer/gate.js +7 -7
- package/dist/computer/host/ca-trust.d.ts +88 -0
- package/dist/computer/host/ca-trust.js +303 -0
- package/dist/computer/host/container-deps.d.ts +54 -0
- package/dist/computer/host/container-deps.js +224 -0
- package/dist/computer/host/container-fault.d.ts +38 -0
- package/dist/computer/host/container-fault.js +58 -0
- package/dist/computer/host/git-host.d.ts +62 -0
- package/dist/computer/host/git-host.js +156 -0
- package/dist/computer/host/git-identity.d.ts +57 -0
- package/dist/computer/host/git-identity.js +147 -0
- package/dist/computer/host/install-job.d.ts +121 -0
- package/dist/computer/host/install-job.js +766 -0
- package/dist/computer/host/sync.d.ts +111 -0
- package/dist/computer/host/sync.js +124 -0
- package/dist/computer/host/wake.d.ts +19 -0
- package/dist/computer/host/wake.js +10 -0
- package/dist/computer/host/workspace.d.ts +424 -0
- package/dist/computer/host/workspace.js +1258 -0
- package/dist/computer/index.d.ts +70 -50
- package/dist/computer/index.js +79 -767
- package/dist/computer/install.d.ts +8 -12
- package/dist/computer/install.js +6 -9
- package/dist/computer/paths.d.ts +21 -16
- package/dist/computer/paths.js +40 -38
- package/dist/computer/read.d.ts +18 -22
- package/dist/computer/read.js +24 -27
- package/dist/computer/render.d.ts +28 -0
- package/dist/computer/render.js +34 -1
- package/dist/computer/shell.d.ts +45 -0
- package/dist/computer/shell.js +80 -0
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +269 -0
- package/dist/computer/tools-file.d.ts +4 -0
- package/dist/computer/tools-file.js +124 -0
- package/dist/computer/tools-find.d.ts +3 -0
- package/dist/computer/tools-find.js +216 -0
- package/dist/recall/index.d.ts +9 -3
- package/dist/recall/index.js +22 -12
- package/dist/repo/checkout.d.ts +34 -4
- package/dist/repo/checkout.js +36 -1
- package/dist/repo/context.d.ts +109 -0
- package/dist/repo/context.js +530 -0
- package/dist/repo/index.d.ts +6 -29
- package/dist/repo/index.js +17 -1027
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-forge.d.ts +4 -0
- package/dist/repo/tools-forge.js +179 -0
- package/dist/repo/tools-review.d.ts +3 -0
- package/dist/repo/tools-review.js +373 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +221 -0
- package/dist/triage/index.d.ts +6 -1
- package/dist/triage/index.js +32 -23
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.js +3 -3
- package/package.json +21 -15
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { FORGE_PAGE_SIZE } from "./context.js";
|
|
4
|
+
/** A review as a conversation: whether it landed, what it said, and answering it. */
|
|
5
|
+
/**
|
|
6
|
+
* How many pages of review threads one `repo_pr_threads` call will walk.
|
|
7
|
+
*
|
|
8
|
+
* A bound rather than a full walk, for the reason {@link FORGE_PAGE_SIZE} is one
|
|
9
|
+
* — but unlike the single page everything else here takes, threads genuinely
|
|
10
|
+
* have to page: a review long enough to spill one puts its newest threads on the
|
|
11
|
+
* last page, and those are exactly the ones an agent was sent to answer. So this
|
|
12
|
+
* is high enough that a real review never reaches it and low enough that a
|
|
13
|
+
* pathological pull request cannot turn one tool call into an unbounded number of
|
|
14
|
+
* round trips.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_THREAD_PAGES = 10;
|
|
17
|
+
/**
|
|
18
|
+
* How much of one thread's conversation is read back.
|
|
19
|
+
*
|
|
20
|
+
* Both ends, because the two uses pull opposite ways: what the reviewer asked for
|
|
21
|
+
* is at the top, and whether a reply landed is at the bottom. A thread shorter
|
|
22
|
+
* than this is returned whole and the two selections are the same comments, which
|
|
23
|
+
* is why the render de-duplicates on the count rather than on the text.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_THREAD_COMMENTS = 6;
|
|
26
|
+
/**
|
|
27
|
+
* The review threads on one pull request.
|
|
28
|
+
*
|
|
29
|
+
* `isResolved` is the field the whole workflow turns on and REST does not expose
|
|
30
|
+
* at all — which is why these three operations are GraphQL while everything else
|
|
31
|
+
* in this file is REST. Both ends of each thread are selected; see
|
|
32
|
+
* {@link MAX_THREAD_COMMENTS} for why one end is not enough.
|
|
33
|
+
*/
|
|
34
|
+
const REVIEW_THREADS_QUERY = `
|
|
35
|
+
query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
36
|
+
repository(owner:$owner,name:$repo){
|
|
37
|
+
pullRequest(number:$number){
|
|
38
|
+
reviewThreads(first:${FORGE_PAGE_SIZE},after:$after){
|
|
39
|
+
pageInfo{hasNextPage endCursor}
|
|
40
|
+
nodes{
|
|
41
|
+
id isResolved isOutdated path line
|
|
42
|
+
opening: comments(first:1){nodes{author{login} body}}
|
|
43
|
+
latest: comments(last:${MAX_THREAD_COMMENTS}){totalCount nodes{author{login} body}}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}`;
|
|
49
|
+
/**
|
|
50
|
+
* Who is currently *asked* to review, apps included.
|
|
51
|
+
*
|
|
52
|
+
* REST's `pulls/{n}/requested_reviewers` carries `users` and `teams` and no
|
|
53
|
+
* third key, so a Bot reviewer — Copilot is one — is absent from a request that
|
|
54
|
+
* is live, and its answer for "Copilot is working on it right now" is byte for
|
|
55
|
+
* byte its answer for "nobody was ever asked". GraphQL types the requested
|
|
56
|
+
* reviewer as a union with `Bot` in it, which is what makes the two
|
|
57
|
+
* distinguishable at all.
|
|
58
|
+
*/
|
|
59
|
+
const REVIEW_REQUESTS_QUERY = `
|
|
60
|
+
query($owner:String!,$repo:String!,$number:Int!){
|
|
61
|
+
repository(owner:$owner,name:$repo){
|
|
62
|
+
pullRequest(number:$number){
|
|
63
|
+
reviewRequests(first:${FORGE_PAGE_SIZE}){
|
|
64
|
+
nodes{
|
|
65
|
+
requestedReviewer{
|
|
66
|
+
... on User{login}
|
|
67
|
+
... on Bot{login}
|
|
68
|
+
... on Team{slug}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}`;
|
|
75
|
+
/** Which pull request, in which repository, a thread id names. */
|
|
76
|
+
const THREAD_OWNER_QUERY = `
|
|
77
|
+
query($id:ID!){
|
|
78
|
+
node(id:$id){
|
|
79
|
+
... on PullRequestReviewThread {
|
|
80
|
+
pullRequest{number repository{nameWithOwner}}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}`;
|
|
84
|
+
const THREAD_REPLY_MUTATION = `
|
|
85
|
+
mutation($id:ID!,$body:String!){
|
|
86
|
+
addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$id,body:$body}){
|
|
87
|
+
comment{url}
|
|
88
|
+
}
|
|
89
|
+
}`;
|
|
90
|
+
const THREAD_RESOLVE_MUTATION = `
|
|
91
|
+
mutation($id:ID!){
|
|
92
|
+
resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
|
|
93
|
+
}`;
|
|
94
|
+
/** One thread, as the model reads it: where it is, its id, and the conversation. */
|
|
95
|
+
function renderThread(t) {
|
|
96
|
+
const where = t.path
|
|
97
|
+
? `${t.path}${typeof t.line === "number" ? `:${t.line}` : ""}`
|
|
98
|
+
: "(no file)";
|
|
99
|
+
const total = t.latest?.totalCount ?? 0;
|
|
100
|
+
const tail = t.latest?.nodes ?? [];
|
|
101
|
+
// Whole when it fits, and both ends with the middle named when it does not.
|
|
102
|
+
// The opening comment is the reviewer's actual point and the tail is where a
|
|
103
|
+
// reply this plugin sent would be, so neither end can be the one dropped.
|
|
104
|
+
const conversation = total > tail.length
|
|
105
|
+
? [
|
|
106
|
+
...(t.opening?.nodes ?? []),
|
|
107
|
+
{
|
|
108
|
+
author: { login: "…" },
|
|
109
|
+
body: `(${total - tail.length - 1} earlier replies not shown)`
|
|
110
|
+
},
|
|
111
|
+
...tail
|
|
112
|
+
]
|
|
113
|
+
: tail;
|
|
114
|
+
return [
|
|
115
|
+
`--- ${where}${t.isResolved ? " [resolved]" : ""}${t.isOutdated ? " [outdated]" : ""}`,
|
|
116
|
+
`id: ${t.id}`,
|
|
117
|
+
...conversation.map((c) => `${c.author?.login ?? "unknown"}: ${c.body ?? ""}`)
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The owner and repository as their own names, rather than as REST path segments.
|
|
122
|
+
*
|
|
123
|
+
* {@link file://./context.ts forgeRepo} percent-encodes both, because that is
|
|
124
|
+
* what makes a path safe. A GraphQL *variable* is not a path — it is compared
|
|
125
|
+
* against the repository's actual name — so an encoded one would silently stop
|
|
126
|
+
* matching the moment a name contains a character worth encoding.
|
|
127
|
+
*/
|
|
128
|
+
function decoded(target) {
|
|
129
|
+
return {
|
|
130
|
+
owner: decodeURIComponent(target.owner),
|
|
131
|
+
repo: decodeURIComponent(target.repo)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The review bot's logins, which GitHub does not spell the same way twice.
|
|
136
|
+
*
|
|
137
|
+
* One account arrives as `Copilot` from the issue timeline, as
|
|
138
|
+
* `copilot-pull-request-reviewer` from GraphQL, and as
|
|
139
|
+
* `copilot-pull-request-reviewer[bot]` from `pulls/{n}/reviews`. Compared
|
|
140
|
+
* exactly against the login a caller names, the request matches and the review
|
|
141
|
+
* never does, so `repo_pr_review_status` reports "none pending — waiting will
|
|
142
|
+
* not change it" at the moment the review lands. That is the one answer a
|
|
143
|
+
* polling caller stops on, so the mismatch does not degrade the tool, it
|
|
144
|
+
* inverts it.
|
|
145
|
+
*
|
|
146
|
+
* An alternation of the spellings that account answers to, and deliberately not
|
|
147
|
+
* a `copilot-` prefix: `copilot-swe-agent` is a **different** account, one that
|
|
148
|
+
* opens pull requests rather than reviewing them, so a prefix would let its
|
|
149
|
+
* request or its authorship answer a question asked about the reviewer.
|
|
150
|
+
*/
|
|
151
|
+
const COPILOT_LOGIN = /^copilot(-pull-request-reviewer)?$/;
|
|
152
|
+
/**
|
|
153
|
+
* Whether a login from the API is the reviewer the caller asked about.
|
|
154
|
+
*
|
|
155
|
+
* `[bot]` is a suffix REST appends to an app's login and GraphQL does not, so it
|
|
156
|
+
* is never part of the identity — stripping it is what lets a caller name any
|
|
157
|
+
* app reviewer the way GitHub's UI shows it.
|
|
158
|
+
*/
|
|
159
|
+
function sameReviewer(a, b) {
|
|
160
|
+
const bare = (login) => login.toLowerCase().replace(/\[bot\]$/, "");
|
|
161
|
+
const [x, y] = [bare(a), bare(b)];
|
|
162
|
+
return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
|
|
163
|
+
}
|
|
164
|
+
export function reviewTools(ctx) {
|
|
165
|
+
const { bounded, forge, forgeGraphql, forgeRepo } = ctx;
|
|
166
|
+
return {
|
|
167
|
+
repo_pr_review_status: tool({
|
|
168
|
+
description: "Say whether a reviewer has finished reviewing a pull request in the repository you have checked out. Use it before reading review comments: a review that has not landed has left no comments, and an empty thread list means nothing yet.",
|
|
169
|
+
inputSchema: z.object({
|
|
170
|
+
dir: z.string().describe("The checkout directory"),
|
|
171
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
172
|
+
reviewer: z
|
|
173
|
+
.string()
|
|
174
|
+
.optional()
|
|
175
|
+
.describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
|
|
176
|
+
}),
|
|
177
|
+
execute: async ({ dir, number, reviewer }) => {
|
|
178
|
+
const target = await forgeRepo(dir);
|
|
179
|
+
if ("refusal" in target)
|
|
180
|
+
return target.refusal;
|
|
181
|
+
const { owner, repo } = target;
|
|
182
|
+
const who = reviewer ?? "Copilot";
|
|
183
|
+
// A *pending* request outranks any review already on the pull request,
|
|
184
|
+
// and that ordering is the whole logic here. GitHub clears the request
|
|
185
|
+
// when a review is submitted and writes a new one when a re-review is
|
|
186
|
+
// asked for, so "still requested" is true now and "has reviewed" is
|
|
187
|
+
// about the past. Reading them the other way round reports a re-review
|
|
188
|
+
// as finished the moment it is asked for.
|
|
189
|
+
const requested = await forgeGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
|
|
190
|
+
if (!requested.ok)
|
|
191
|
+
return bounded(requested.message);
|
|
192
|
+
const waiting = requested.data.repository?.pullRequest?.reviewRequests;
|
|
193
|
+
if (!waiting)
|
|
194
|
+
return `#${number} is not a pull request in ${owner}/${repo}`;
|
|
195
|
+
const pending = (waiting.nodes ?? []).some((n) => {
|
|
196
|
+
const name = n?.requestedReviewer?.login ?? n?.requestedReviewer?.slug;
|
|
197
|
+
return !!name && sameReviewer(name, who);
|
|
198
|
+
});
|
|
199
|
+
if (pending)
|
|
200
|
+
return `${who} has been asked to review #${number} and has not finished.`;
|
|
201
|
+
const reviews = await forge("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${FORGE_PAGE_SIZE}`);
|
|
202
|
+
if (!reviews.ok)
|
|
203
|
+
return bounded(reviews.message);
|
|
204
|
+
const all = reviews.data;
|
|
205
|
+
const submitted = all.filter((r) => !!r.user?.login &&
|
|
206
|
+
sameReviewer(r.user.login, who) &&
|
|
207
|
+
// A `PENDING` review is a draft its author has not sent, and it is
|
|
208
|
+
// visible to whoever holds the token that wrote it. Counting one as
|
|
209
|
+
// finished reports a review nobody has read, which is the answer a
|
|
210
|
+
// poll loop acts on by stopping.
|
|
211
|
+
(r.state ?? "").toUpperCase() !== "PENDING");
|
|
212
|
+
const theirs = submitted.at(-1);
|
|
213
|
+
if (!theirs) {
|
|
214
|
+
// The endpoint pages oldest-first, so a match beyond the first page is
|
|
215
|
+
// **newer** than everything read — which is to say it is exactly the
|
|
216
|
+
// review being waited for. So a truncated read cannot answer the
|
|
217
|
+
// question at all, and must not answer it with "waiting will not help":
|
|
218
|
+
// that is the one reply a caller ends its polling on.
|
|
219
|
+
if (all.length >= FORGE_PAGE_SIZE)
|
|
220
|
+
return bounded(`#${number} has at least ${FORGE_PAGE_SIZE} reviews and only the oldest were read, ` +
|
|
221
|
+
`so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
|
|
222
|
+
`or look for threads with repo_pr_threads.`);
|
|
223
|
+
return bounded(`#${number} has no review from ${who}, and none is pending. ` +
|
|
224
|
+
`Nobody asked them, or the request was withdrawn — waiting will not change it.`);
|
|
225
|
+
}
|
|
226
|
+
return bounded(`${who} reviewed #${number}${theirs.submitted_at ? ` at ${theirs.submitted_at}` : ""}` +
|
|
227
|
+
`${theirs.state ? ` (${theirs.state.toLowerCase()})` : ""}. ` +
|
|
228
|
+
`Read what it said with repo_pr_threads — a review can finish having left no comments.`);
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
repo_pr_threads: tool({
|
|
232
|
+
description: "Read the review threads on a pull request in the repository you have checked out — the inline comments a reviewer left on specific lines, which are not on the conversation timeline repo_issue_view reads. Unresolved ones only, unless you ask for all.",
|
|
233
|
+
inputSchema: z.object({
|
|
234
|
+
dir: z.string().describe("The checkout directory"),
|
|
235
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
236
|
+
includeResolved: z
|
|
237
|
+
.boolean()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe("Include threads already resolved. Defaults to false.")
|
|
240
|
+
}),
|
|
241
|
+
execute: async ({ dir, number, includeResolved }) => {
|
|
242
|
+
const target = await forgeRepo(dir);
|
|
243
|
+
if ("refusal" in target)
|
|
244
|
+
return target.refusal;
|
|
245
|
+
const { owner, repo } = decoded(target);
|
|
246
|
+
const threads = [];
|
|
247
|
+
let after = null;
|
|
248
|
+
let truncated = false;
|
|
249
|
+
// Paged, and that is not an optimisation. A review long enough to spill a
|
|
250
|
+
// page puts its newest threads on the last one — exactly the threads the
|
|
251
|
+
// model was sent to answer — so a single page can report a busy review as
|
|
252
|
+
// clean. The page cap is what stops a pathological pull request becoming
|
|
253
|
+
// an unbounded number of round trips.
|
|
254
|
+
for (let page = 0;; page++) {
|
|
255
|
+
if (page >= MAX_THREAD_PAGES) {
|
|
256
|
+
truncated = true;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
const answered = await forgeGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
|
|
260
|
+
owner,
|
|
261
|
+
repo,
|
|
262
|
+
number,
|
|
263
|
+
after
|
|
264
|
+
});
|
|
265
|
+
if (!answered.ok)
|
|
266
|
+
return bounded(answered.message);
|
|
267
|
+
const page_ = answered.data.repository?.pullRequest?.reviewThreads;
|
|
268
|
+
if (!page_)
|
|
269
|
+
return `#${number} is not a pull request in ${owner}/${repo}`;
|
|
270
|
+
threads.push(...(page_.nodes ?? []));
|
|
271
|
+
if (!page_.pageInfo?.hasNextPage)
|
|
272
|
+
break;
|
|
273
|
+
after = page_.pageInfo.endCursor ?? null;
|
|
274
|
+
if (!after)
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
// Said once, and said on **every** exit including the empty one. A run
|
|
278
|
+
// that stopped early and found nothing open in what it did read must not
|
|
279
|
+
// answer "nothing to do": the unread pages are exactly where the newest
|
|
280
|
+
// threads are, which is the false-clean result the paging exists to
|
|
281
|
+
// prevent — reintroduced at the one exit that skipped the warning.
|
|
282
|
+
const unread = truncated
|
|
283
|
+
? `\n\n(stopped after ${MAX_THREAD_PAGES} pages, so this is not the whole review — there are more threads than were read)`
|
|
284
|
+
: "";
|
|
285
|
+
const shown = includeResolved
|
|
286
|
+
? threads
|
|
287
|
+
: threads.filter((t) => !t.isResolved);
|
|
288
|
+
if (shown.length === 0)
|
|
289
|
+
return bounded((includeResolved
|
|
290
|
+
? `#${number} has no review threads.`
|
|
291
|
+
: `#${number} has no unresolved review threads.` +
|
|
292
|
+
(threads.length > 0
|
|
293
|
+
? ` (${threads.length} resolved — pass includeResolved to read them.)`
|
|
294
|
+
: "")) + unread);
|
|
295
|
+
const rendered = shown.map((t) => renderThread(t)).join("\n\n");
|
|
296
|
+
return bounded(rendered + unread);
|
|
297
|
+
}
|
|
298
|
+
}),
|
|
299
|
+
repo_pr_thread_reply: tool({
|
|
300
|
+
description: "Answer one review thread on a pull request in the repository you have checked out, and resolve it. Reply with what you changed and where, or with why you did not — either way the thread ends resolved unless you say otherwise.",
|
|
301
|
+
inputSchema: z.object({
|
|
302
|
+
dir: z.string().describe("The checkout directory"),
|
|
303
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
304
|
+
threadId: z
|
|
305
|
+
.string()
|
|
306
|
+
.describe("The thread's id, exactly as repo_pr_threads printed it"),
|
|
307
|
+
body: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
|
|
311
|
+
resolve: z
|
|
312
|
+
.boolean()
|
|
313
|
+
.optional()
|
|
314
|
+
.describe("Resolve the thread after replying. Defaults to true.")
|
|
315
|
+
}),
|
|
316
|
+
execute: async ({ dir, number, threadId, body, resolve }) => {
|
|
317
|
+
const target = await forgeRepo(dir);
|
|
318
|
+
if ("refusal" in target)
|
|
319
|
+
return target.refusal;
|
|
320
|
+
const { owner, repo } = decoded(target);
|
|
321
|
+
// A thread id is a global node id, so unlike every other tool here this
|
|
322
|
+
// one takes something that can name a pull request in a repository
|
|
323
|
+
// nobody checked out — the reach `forgeRepo` exists to prevent. Checked
|
|
324
|
+
// rather than trusted: the id came from a tool result the model read,
|
|
325
|
+
// and a model that misremembers one must not write into a stranger's
|
|
326
|
+
// review.
|
|
327
|
+
const belongs = await forgeGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
|
|
328
|
+
if (!belongs.ok)
|
|
329
|
+
return bounded(belongs.message);
|
|
330
|
+
const node = belongs.data.node;
|
|
331
|
+
if (!node?.pullRequest)
|
|
332
|
+
return `${threadId} is not a review thread — read the ids with repo_pr_threads`;
|
|
333
|
+
const at = node.pullRequest.repository?.nameWithOwner ?? "";
|
|
334
|
+
if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
|
|
335
|
+
node.pullRequest.number !== number)
|
|
336
|
+
return (`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
|
|
337
|
+
`Read the ids for this pull request with repo_pr_threads.`);
|
|
338
|
+
// Resolve-only. The reply and the resolve are two calls, so they fail
|
|
339
|
+
// independently — and the tool that tells a caller "the reply landed,
|
|
340
|
+
// resolve it alone" has to give it a way to do that. Without this the
|
|
341
|
+
// only route back is a second reply the reviewer has already read.
|
|
342
|
+
if (body === undefined) {
|
|
343
|
+
if (resolve === false)
|
|
344
|
+
return "nothing to do: give a body to reply, or leave resolve unset to resolve the thread";
|
|
345
|
+
const only = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
346
|
+
return only.ok
|
|
347
|
+
? `resolved ${threadId}`
|
|
348
|
+
: bounded(`resolving the thread failed: ${only.message}`);
|
|
349
|
+
}
|
|
350
|
+
const replied = await forgeGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
|
|
351
|
+
if (!replied.ok)
|
|
352
|
+
// The hazard `repo_pr_comment` names, and worse here: a reply that
|
|
353
|
+
// landed and an answer that did not arrive look identical, and the
|
|
354
|
+
// retry leaves the reviewer two of them.
|
|
355
|
+
return bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
|
|
356
|
+
`reply may exist anyway — read the thread back with repo_pr_threads before retrying.`);
|
|
357
|
+
const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
|
|
358
|
+
if (resolve === false)
|
|
359
|
+
return `replied: ${url} (left unresolved)`;
|
|
360
|
+
// Reported separately, never folded into the reply's own failure: a
|
|
361
|
+
// thread that was answered and not resolved needs resolving, and one
|
|
362
|
+
// that was never answered needs answering. Saying "it failed" covers
|
|
363
|
+
// both and tells the model to do the wrong one.
|
|
364
|
+
const resolved = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
365
|
+
if (!resolved.ok)
|
|
366
|
+
return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
|
|
367
|
+
`The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
|
|
368
|
+
`threadId with no body to resolve it on its own.`);
|
|
369
|
+
return `replied and resolved: ${url}`;
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PROTECTED_BRANCHES, UNSAFE_BRANCH } from "./url.js";
|
|
4
|
+
import { resolveDefaultBranch } from "./checkout.js";
|
|
5
|
+
/** Reading the working tree, committing it, and publishing the branch. */
|
|
6
|
+
export function worktreeTools(ctx) {
|
|
7
|
+
const { author, bounded, logFailure, plain, origin, pushBranch } = ctx;
|
|
8
|
+
return {
|
|
9
|
+
repo_status: tool({
|
|
10
|
+
description: "Show which files changed in the checkout.",
|
|
11
|
+
inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
|
|
12
|
+
execute: async ({ dir }) => {
|
|
13
|
+
const result = await plain("status --short", dir);
|
|
14
|
+
// "(no changes)" is a claim about the tree. A command that failed
|
|
15
|
+
// supports no claim about anything, and reporting one as the other sends
|
|
16
|
+
// the model on to commit against a repository it cannot read.
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
logFailure("repo_status", result);
|
|
19
|
+
return bounded(`could not read the status of ${dir}: ${result.stderr || result.stdout}`);
|
|
20
|
+
}
|
|
21
|
+
return bounded(result.stdout.trim()) || "(no changes)";
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
repo_diff: tool({
|
|
25
|
+
description: "Show the current diff. Read this before committing — it is the cheapest way to catch an edit that did more than you intended. Pass stat:true first on a large change to see which files moved and by how much, then read the full diff of what matters.",
|
|
26
|
+
inputSchema: z.object({
|
|
27
|
+
dir: z.string().describe("Checkout directory"),
|
|
28
|
+
staged: z.boolean().optional().describe("Show staged changes instead"),
|
|
29
|
+
stat: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Summarise as a per-file changed-line count instead of the full patch")
|
|
33
|
+
}),
|
|
34
|
+
execute: async ({ dir, staged, stat }) => {
|
|
35
|
+
const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(" ");
|
|
38
|
+
const result = await plain(`diff ${flags}`, dir);
|
|
39
|
+
// Same reasoning as `repo_status`: "(no diff)" and "the diff could not
|
|
40
|
+
// be read" are opposite answers, and this is the tool a reviewing agent
|
|
41
|
+
// trusts most.
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
logFailure("repo_diff", result);
|
|
44
|
+
return bounded(`could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
|
|
45
|
+
}
|
|
46
|
+
// Truncated from the middle rather than the end: the head of a diff and
|
|
47
|
+
// its tail are both informative, and the middle of a large one rarely
|
|
48
|
+
// is. A model that needs the part that was dropped can ask for `stat`
|
|
49
|
+
// and then read the file.
|
|
50
|
+
return bounded(result.stdout.trim()) || "(no diff)";
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
repo_commit: tool({
|
|
54
|
+
description: "Stage everything and commit. Write the message for a reviewer reading it in the log a year from now: what changed and why, not how.",
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
dir: z.string().describe("Checkout directory"),
|
|
57
|
+
message: z.string().describe("Commit message")
|
|
58
|
+
}),
|
|
59
|
+
execute: async ({ dir, message }) => {
|
|
60
|
+
// Checked rather than fired and forgotten. A failed `add` leaves the
|
|
61
|
+
// index holding less than the model believes, and the commit that
|
|
62
|
+
// follows still succeeds — so the round reports a commit that quietly
|
|
63
|
+
// does not contain the change.
|
|
64
|
+
const staged = await plain("add -A", dir);
|
|
65
|
+
if (!staged.success) {
|
|
66
|
+
logFailure("repo_commit", staged);
|
|
67
|
+
return bounded(`could not stage the changes in ${dir}: ${staged.stderr || staged.stdout}`);
|
|
68
|
+
}
|
|
69
|
+
// The message is model-authored free text — quotes, backticks, newlines,
|
|
70
|
+
// `$(…)`. Expanded from the environment inside the container, all of
|
|
71
|
+
// that is inert; interpolated into the command string, none of it is.
|
|
72
|
+
const result = await plain(`commit -m "$GIT_COMMIT_MESSAGE"`, dir, {
|
|
73
|
+
GIT_COMMIT_MESSAGE: message,
|
|
74
|
+
// On the commit as well as in the checkout's config, and this is
|
|
75
|
+
// the copy that is current: config is written when a checkout is set
|
|
76
|
+
// up, and the environment is read at the commit. A workspace that
|
|
77
|
+
// outlived a change of identity commits under the new one.
|
|
78
|
+
GIT_AUTHOR_NAME: author.name,
|
|
79
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
80
|
+
GIT_COMMITTER_NAME: author.name,
|
|
81
|
+
GIT_COMMITTER_EMAIL: author.email
|
|
82
|
+
});
|
|
83
|
+
if (!result.success && /nothing to commit/i.test(result.stdout))
|
|
84
|
+
return "nothing to commit — the working tree is clean";
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
logFailure("repo_commit", result);
|
|
87
|
+
return bounded(`commit failed: ${result.stderr || result.stdout}`);
|
|
88
|
+
}
|
|
89
|
+
return bounded(result.stdout.trim());
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
repo_push: tool({
|
|
93
|
+
description: "Push a branch to the remote. Refuses to push to a protected branch and refuses to force-push — open a pull request instead.",
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
dir: z.string().describe("Checkout directory"),
|
|
96
|
+
branch: z
|
|
97
|
+
.string()
|
|
98
|
+
.describe("Branch name to create and push, e.g. 'coder/add-json-flag'")
|
|
99
|
+
}),
|
|
100
|
+
execute: async ({ dir, branch }) => {
|
|
101
|
+
// Enforced here rather than in the prompt: a guardrail a model can talk
|
|
102
|
+
// itself out of is not a guardrail.
|
|
103
|
+
//
|
|
104
|
+
// Shape first, then the name. `git push origin <name>` reads `<name>`
|
|
105
|
+
// as a refspec, so `+x:main` is a force push to main that the name
|
|
106
|
+
// check below would wave through — it only ever sees a literal string.
|
|
107
|
+
if (UNSAFE_BRANCH.test(branch))
|
|
108
|
+
return `"${branch}" is not a plain branch name — use something like "coder/add-json-flag"`;
|
|
109
|
+
if (PROTECTED_BRANCHES.has(branch))
|
|
110
|
+
return `refusing to push to "${branch}" — push a work branch and open a pull request`;
|
|
111
|
+
// The repository's *own* default, which is often none of the four names
|
|
112
|
+
// above: a repo whose trunk is `release` deserves the same protection.
|
|
113
|
+
// A command that never ran stops here rather than standing the guards
|
|
114
|
+
// down — the same rule as the two probes further down, and the reason
|
|
115
|
+
// {@link resolveDefaultBranch} reports the two separately.
|
|
116
|
+
const head = await resolveDefaultBranch(plain, dir);
|
|
117
|
+
if (head.unreachable)
|
|
118
|
+
return bounded(`could not push "${branch}": ${head.unreachable}`);
|
|
119
|
+
const defaultBranch = head.branch;
|
|
120
|
+
if (defaultBranch && defaultBranch === branch)
|
|
121
|
+
return `refusing to push to "${branch}" — it is this repository's default branch; push a work branch and open a pull request`;
|
|
122
|
+
const { remote, unreachable } = await origin(dir);
|
|
123
|
+
if (unreachable)
|
|
124
|
+
return bounded(`could not push "${branch}": ${unreachable}`);
|
|
125
|
+
if (!remote)
|
|
126
|
+
return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
|
|
127
|
+
// Switch to the branch, or create it — but never *reset* it.
|
|
128
|
+
//
|
|
129
|
+
// `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
|
|
130
|
+
// holds the commit it force-moves it to wherever HEAD is now. The commit
|
|
131
|
+
// survives only as an unreferenced object, and what gets pushed is an
|
|
132
|
+
// empty branch with a pull request opened on it — a complete loss
|
|
133
|
+
// dressed up as success.
|
|
134
|
+
const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
135
|
+
// Same reasoning as the probe in `repo_clone`: `!success` means "no such
|
|
136
|
+
// branch, create it" only when git was there to say so.
|
|
137
|
+
if (exists.unreachable)
|
|
138
|
+
return bounded(`could not push "${branch}": ${exists.stderr}`);
|
|
139
|
+
const checkout = await plain(exists.success
|
|
140
|
+
? `checkout "$REPO_BRANCH"`
|
|
141
|
+
: `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
142
|
+
if (!checkout.success) {
|
|
143
|
+
logFailure("repo_push", checkout);
|
|
144
|
+
return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
|
|
145
|
+
}
|
|
146
|
+
// Nothing to push is a bug upstream of here, not a no-op.
|
|
147
|
+
//
|
|
148
|
+
// A branch level with the default branch means the commit went
|
|
149
|
+
// somewhere else, or was never made. Pushing it succeeds, `repo_open_pr`
|
|
150
|
+
// opens an empty pull request, and the round reports a URL as if the
|
|
151
|
+
// work had landed — the one outcome worse than an error.
|
|
152
|
+
//
|
|
153
|
+
// The guard is skipped only when there is no baseline to compare
|
|
154
|
+
// against: a repository with no `origin/HEAD` is unusual but legitimate,
|
|
155
|
+
// and guessing is worse than not guarding. Once there *is* one, a
|
|
156
|
+
// comparison that fails is an unanswered question rather than a pass —
|
|
157
|
+
// the same rule the three probes around this one follow.
|
|
158
|
+
if (defaultBranch) {
|
|
159
|
+
const ahead = await plain(`rev-list --count "origin/$REPO_BASE..HEAD"`, dir, {
|
|
160
|
+
REPO_BASE: defaultBranch
|
|
161
|
+
});
|
|
162
|
+
if (ahead.unreachable)
|
|
163
|
+
return bounded(`could not push "${branch}": ${ahead.stderr}`);
|
|
164
|
+
if (!ahead.success) {
|
|
165
|
+
logFailure("repo_push", ahead);
|
|
166
|
+
return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
|
|
167
|
+
`against origin/${defaultBranch} failed with ` +
|
|
168
|
+
`${ahead.stderr || ahead.stdout || "no output"}\n` +
|
|
169
|
+
`Nothing was pushed. Pushing without that answer risks an empty pull ` +
|
|
170
|
+
`request reported as success, so check repo_status and repo_diff first.`);
|
|
171
|
+
}
|
|
172
|
+
if (ahead.stdout.trim() === "0") {
|
|
173
|
+
return (`refusing to push "${branch}" — it has no commits that origin/${defaultBranch} ` +
|
|
174
|
+
`does not already have, so the pull request would be empty. Check repo_status ` +
|
|
175
|
+
`and repo_diff: either the change was never committed, or it was committed on ` +
|
|
176
|
+
`a different branch.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// A pre-flight check, not a value anyone downstream consumes: `push`
|
|
180
|
+
// takes the branch *name* and resolves it itself, against the same
|
|
181
|
+
// files. What this buys is the failure arriving here, where the sentence
|
|
182
|
+
// can name the branch and the checkout, instead of surfacing as a push
|
|
183
|
+
// that rejects a ref nobody can see.
|
|
184
|
+
const tip = await plain(`rev-parse --verify "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
185
|
+
if (!tip.success || !tip.stdout.trim()) {
|
|
186
|
+
logFailure("repo_push", tip);
|
|
187
|
+
return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
|
|
188
|
+
}
|
|
189
|
+
const result = await pushBranch(dir, remote.url, branch);
|
|
190
|
+
if (!result.success) {
|
|
191
|
+
logFailure("repo_push", result);
|
|
192
|
+
return bounded(`push failed: ${result.stderr || result.stdout}`);
|
|
193
|
+
}
|
|
194
|
+
// What `--set-upstream` would do as a side effect of the push, written
|
|
195
|
+
// here because the push happens on the host's side: a subagent reaching
|
|
196
|
+
// for a bare `git push` in the shell should still find the branch
|
|
197
|
+
// tracking something.
|
|
198
|
+
//
|
|
199
|
+
// Checked, but *reported* rather than raised: this is the one place in
|
|
200
|
+
// the plugin where a failed command is not a failed operation. The push
|
|
201
|
+
// has landed, saying so is the important half, and all that is lost is
|
|
202
|
+
// the convenience these two keys buy.
|
|
203
|
+
const remoteSet = await plain(`config "branch.$REPO_BRANCH.remote" origin`, dir, { REPO_BRANCH: branch });
|
|
204
|
+
const mergeSet = await plain(`config "branch.$REPO_BRANCH.merge" "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
205
|
+
const untracked = [remoteSet, mergeSet].find((r) => !r.success);
|
|
206
|
+
if (untracked) {
|
|
207
|
+
// Named apart from the push, which succeeded — a log line reading
|
|
208
|
+
// "repo_push failed" next to a branch that is on the remote is worse
|
|
209
|
+
// than no log line.
|
|
210
|
+
logFailure("repo_push (upstream tracking)", untracked);
|
|
211
|
+
return bounded(`pushed ${branch}, but could not record what it tracks: ` +
|
|
212
|
+
`${untracked.stderr || untracked.stdout}\n` +
|
|
213
|
+
`The push itself landed. The only consequence is local: a bare ` +
|
|
214
|
+
`\`git push\` from the shell will not know where to send this ` +
|
|
215
|
+
`branch, and needs \`git push -u origin ${branch}\` once.`);
|
|
216
|
+
}
|
|
217
|
+
return `pushed ${branch}`;
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
};
|
|
221
|
+
}
|
package/dist/triage/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { LanguageModel } from "ai";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import type { SessionMessage } from "agents/experimental/memory/session";
|
|
4
3
|
import type { AgentPlugin } from "@dynamicagents/core";
|
|
4
|
+
import type { SessionMessage } from "@dynamicagents/core/agent";
|
|
5
5
|
/**
|
|
6
6
|
* The classifier's output. The booleans are **not** metadata for the caller —
|
|
7
7
|
* only `should_reply` is ever read. They exist to shape the generation itself:
|
|
@@ -38,6 +38,11 @@ export interface TriageTuning {
|
|
|
38
38
|
* in one gateway.
|
|
39
39
|
*/
|
|
40
40
|
aiGatewayId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The `agent` key on the classifier's AI Gateway log rows. Pass the host's
|
|
43
|
+
* `PluginHost.agentName`, for the reason given on {@link aiGatewayId}.
|
|
44
|
+
*/
|
|
45
|
+
agentName?: string;
|
|
41
46
|
/** Workers-AI id for the classifier. Defaults to a small, fast model. */
|
|
42
47
|
modelId?: string;
|
|
43
48
|
/** Trailing messages the classifier sees. Defaults to 12. */
|