@arungeorgesaji/assembly 0.1.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/.env.example +7 -0
- package/LICENSE +674 -0
- package/README.md +455 -0
- package/ngrok-example.yml +9 -0
- package/package.json +23 -0
- package/src/agent-profiles.js +167 -0
- package/src/agent-runner.js +37 -0
- package/src/approval.js +45 -0
- package/src/cli.js +381 -0
- package/src/config.js +90 -0
- package/src/context-builder.js +91 -0
- package/src/doctor.js +151 -0
- package/src/file-updates.js +89 -0
- package/src/follow-up.js +33 -0
- package/src/git-worktree.js +35 -0
- package/src/github-webhooks.js +159 -0
- package/src/github.js +261 -0
- package/src/init.js +91 -0
- package/src/job-store.js +97 -0
- package/src/job-worker.js +390 -0
- package/src/models.js +55 -0
- package/src/openai-agent-runner.js +141 -0
- package/src/orchestrator.js +221 -0
- package/src/patch.js +78 -0
- package/src/planner.js +279 -0
- package/src/repo-inspector.js +88 -0
- package/src/report.js +46 -0
- package/src/result-validation.js +55 -0
- package/src/review-agent-runner.js +127 -0
- package/src/root.js +51 -0
- package/src/run-store.js +104 -0
- package/src/scope.js +107 -0
- package/src/slack-thread-store.js +34 -0
- package/src/slack-webhooks.js +120 -0
- package/src/slack.js +31 -0
- package/src/validation.js +33 -0
- package/src/verification-runner.js +51 -0
- package/src/webhook-server.js +64 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { createRun } from "./orchestrator.js";
|
|
5
|
+
import { createFollowUpRun } from "./follow-up.js";
|
|
6
|
+
import { copyRunRecord, withTemporaryGitWorktree } from "./git-worktree.js";
|
|
7
|
+
import { createGitHubPullRequest, getGitHubPullRequest, updateGitHubPullRequestFromRun } from "./github.js";
|
|
8
|
+
import { readJob, updateJob } from "./job-store.js";
|
|
9
|
+
import { readRun } from "./run-store.js";
|
|
10
|
+
import { postSlackMessage } from "./slack.js";
|
|
11
|
+
import { readSlackThreadState, writeSlackThreadState } from "./slack-thread-store.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
export async function processJob(jobId, { rootDir = process.cwd(), exec = execFileAsync, agentRunner } = {}) {
|
|
16
|
+
const job = await updateJob(jobId, { status: "running", startedAt: new Date().toISOString() }, rootDir);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await processTypedJob(job, { rootDir, exec, agentRunner });
|
|
20
|
+
return updateJob(jobId, {
|
|
21
|
+
status: "complete",
|
|
22
|
+
completedAt: new Date().toISOString(),
|
|
23
|
+
result,
|
|
24
|
+
}, rootDir);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const latestJob = await readJob(jobId, rootDir).catch(() => job);
|
|
27
|
+
await notifyGitHubJobFailure(latestJob, error, { rootDir, exec });
|
|
28
|
+
await notifySlackJobFailure(latestJob, error);
|
|
29
|
+
return updateJob(jobId, {
|
|
30
|
+
status: "failed",
|
|
31
|
+
failedAt: new Date().toISOString(),
|
|
32
|
+
error: error.message,
|
|
33
|
+
}, rootDir);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function notifySlackJobFailure(job, error) {
|
|
38
|
+
if (job.type !== "slack.request") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await postSlackMessage({
|
|
44
|
+
channel: job.payload.channel,
|
|
45
|
+
threadTs: job.payload.threadTs,
|
|
46
|
+
text: formatSlackJobFailureMessage(job, error),
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
// Keep the original job failure as the source of truth.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatSlackJobFailureMessage(job, error) {
|
|
54
|
+
const retryable = isRetryableJobFailure(error);
|
|
55
|
+
return [
|
|
56
|
+
"Assembly could not complete this Slack request.",
|
|
57
|
+
`Job: ${job.id}`,
|
|
58
|
+
job.runId ? `Run: ${job.runId}` : null,
|
|
59
|
+
`Retryable: ${retryable ? "yes" : "no"}`,
|
|
60
|
+
`Error: ${error.message}`,
|
|
61
|
+
`Suggested next action: ${getFailureSuggestedAction(job, error, retryable)}`,
|
|
62
|
+
].filter(Boolean).join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function notifyGitHubJobFailure(job, error, { rootDir, exec }) {
|
|
66
|
+
if (!job.type?.startsWith("github.")) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = formatGitHubJobFailureComment(job, error);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (job.payload?.issueNumber) {
|
|
74
|
+
await exec("gh", ["issue", "comment", String(job.payload.issueNumber), "--body", body], { cwd: rootDir });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (job.payload?.prNumber) {
|
|
79
|
+
await exec("gh", ["pr", "comment", String(job.payload.prNumber), "--body", body], { cwd: rootDir });
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Keep the original job failure as the source of truth.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatGitHubJobFailureComment(job, error) {
|
|
87
|
+
const retryable = isRetryableJobFailure(error);
|
|
88
|
+
return [
|
|
89
|
+
"Assembly could not complete this request.",
|
|
90
|
+
"",
|
|
91
|
+
`Job: ${job.id}`,
|
|
92
|
+
job.runId ? `Run: ${job.runId}` : null,
|
|
93
|
+
job.parentRunId ? `Parent run: ${job.parentRunId}` : null,
|
|
94
|
+
`Retryable: ${retryable ? "yes" : "no"}`,
|
|
95
|
+
"",
|
|
96
|
+
`Error: ${error.message}`,
|
|
97
|
+
"",
|
|
98
|
+
`Suggested next action: ${getFailureSuggestedAction(job, error, retryable)}`,
|
|
99
|
+
].filter(Boolean).join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRetryableJobFailure(error) {
|
|
103
|
+
const message = String(error.message ?? "");
|
|
104
|
+
if (/does not include Assembly run metadata|unsupported job type|invalid signature|invalid JSON/i.test(message)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getFailureSuggestedAction(job, error, retryable) {
|
|
111
|
+
const message = String(error.message ?? "");
|
|
112
|
+
if (/does not include Assembly run metadata/i.test(message)) {
|
|
113
|
+
return "Open a new Assembly issue request or recreate the PR through Assembly so the PR body includes Assembly metadata.";
|
|
114
|
+
}
|
|
115
|
+
if (/working tree has unrelated changes/i.test(message)) {
|
|
116
|
+
return `Retry this job after the branch/worktree is clean: assembly job retry ${job.id}`;
|
|
117
|
+
}
|
|
118
|
+
if (retryable) {
|
|
119
|
+
return `Inspect the job, fix the underlying setup or code issue, then retry it: assembly job inspect ${job.id} --pretty && assembly job retry ${job.id}`;
|
|
120
|
+
}
|
|
121
|
+
return "Create a new request after correcting the event or repository state.";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function getJob(jobId, rootDir = process.cwd()) {
|
|
125
|
+
return readJob(jobId, rootDir);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function processTypedJob(job, context) {
|
|
129
|
+
if (job.type === "github.pr_feedback") {
|
|
130
|
+
return withTemporaryGitWorktree(context.rootDir, context.exec, (worktreeRootDir) => {
|
|
131
|
+
return processGitHubPrFeedbackJob(job, {
|
|
132
|
+
...context,
|
|
133
|
+
rootDir: worktreeRootDir,
|
|
134
|
+
stateRootDir: context.rootDir,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (job.type === "github.issue_request") {
|
|
139
|
+
return withTemporaryGitWorktree(context.rootDir, context.exec, (worktreeRootDir) => {
|
|
140
|
+
return processGitHubIssueRequestJob(job, {
|
|
141
|
+
...context,
|
|
142
|
+
rootDir: worktreeRootDir,
|
|
143
|
+
stateRootDir: context.rootDir,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (job.type === "slack.request") {
|
|
148
|
+
return withTemporaryGitWorktree(context.rootDir, context.exec, (worktreeRootDir) => {
|
|
149
|
+
return processSlackRequestJob(job, {
|
|
150
|
+
...context,
|
|
151
|
+
rootDir: worktreeRootDir,
|
|
152
|
+
stateRootDir: context.rootDir,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`unsupported job type: ${job.type}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function processSlackRequestJob(job, { rootDir, stateRootDir, exec, agentRunner }) {
|
|
160
|
+
const threadRef = getSlackThreadRef(job);
|
|
161
|
+
const threadState = await readSlackThreadState(threadRef, stateRootDir);
|
|
162
|
+
if (threadState?.pullRequest?.number) {
|
|
163
|
+
return processSlackPrFollowUpJob(job, threadState, { rootDir, stateRootDir, exec, agentRunner });
|
|
164
|
+
}
|
|
165
|
+
return processSlackNewPullRequestJob(job, { rootDir, stateRootDir, exec, agentRunner });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function processSlackNewPullRequestJob(job, { rootDir, stateRootDir, exec, agentRunner }) {
|
|
169
|
+
const request = [
|
|
170
|
+
`Slack request from <@${job.payload.user}> in ${job.payload.channel}.`,
|
|
171
|
+
job.payload.text,
|
|
172
|
+
].filter(Boolean).join("\n\n");
|
|
173
|
+
|
|
174
|
+
const run = await createRun(request, {
|
|
175
|
+
rootDir,
|
|
176
|
+
agentRunner,
|
|
177
|
+
metadata: {
|
|
178
|
+
source: {
|
|
179
|
+
provider: "slack",
|
|
180
|
+
kind: job.payload.kind,
|
|
181
|
+
eventId: job.payload.eventId,
|
|
182
|
+
channel: job.payload.channel,
|
|
183
|
+
user: job.payload.user,
|
|
184
|
+
threadTs: job.payload.threadTs,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await updateJob(job.id, { runId: run.runId }, stateRootDir);
|
|
189
|
+
await copyRunRecord(run.runId, rootDir, stateRootDir);
|
|
190
|
+
await assertRunCompleteForDelivery(run.runId, rootDir);
|
|
191
|
+
const pr = await createGitHubPullRequest(run.runId, { rootDir, exec });
|
|
192
|
+
await copyRunRecord(run.runId, rootDir, stateRootDir);
|
|
193
|
+
const prNumber = extractPullRequestNumber(pr.url);
|
|
194
|
+
await writeSlackThreadState({
|
|
195
|
+
...getSlackThreadRef(job),
|
|
196
|
+
runId: run.runId,
|
|
197
|
+
pullRequest: {
|
|
198
|
+
number: prNumber,
|
|
199
|
+
url: pr.url,
|
|
200
|
+
branchName: pr.branchName,
|
|
201
|
+
},
|
|
202
|
+
}, stateRootDir);
|
|
203
|
+
|
|
204
|
+
await postSlackMessage({
|
|
205
|
+
channel: job.payload.channel,
|
|
206
|
+
threadTs: job.payload.threadTs,
|
|
207
|
+
text: `Assembly created ${pr.url} for run ${run.runId}.`,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
runId: run.runId,
|
|
212
|
+
channel: job.payload.channel,
|
|
213
|
+
threadTs: job.payload.threadTs,
|
|
214
|
+
pullRequest: pr,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function processSlackPrFollowUpJob(job, threadState, { rootDir, stateRootDir, exec, agentRunner }) {
|
|
219
|
+
const pr = await getGitHubPullRequest(threadState.pullRequest.number, { rootDir, exec });
|
|
220
|
+
if (!pr.runId) {
|
|
221
|
+
throw new Error(`pull request ${threadState.pullRequest.number} does not include Assembly run metadata`);
|
|
222
|
+
}
|
|
223
|
+
await updateJob(job.id, { parentRunId: pr.runId }, stateRootDir);
|
|
224
|
+
|
|
225
|
+
await exec("git", ["fetch", "origin", pr.branchName], { cwd: rootDir });
|
|
226
|
+
await exec("git", ["checkout", pr.branchName], { cwd: rootDir });
|
|
227
|
+
await exec("git", ["pull", "--ff-only"], { cwd: rootDir });
|
|
228
|
+
await copyRunRecord(pr.runId, stateRootDir, rootDir);
|
|
229
|
+
|
|
230
|
+
const followUp = await createFollowUpRun(pr.runId, job.payload.text, {
|
|
231
|
+
rootDir,
|
|
232
|
+
agentRunner,
|
|
233
|
+
source: {
|
|
234
|
+
provider: "slack",
|
|
235
|
+
kind: job.payload.kind,
|
|
236
|
+
eventId: job.payload.eventId,
|
|
237
|
+
channel: job.payload.channel,
|
|
238
|
+
user: job.payload.user,
|
|
239
|
+
threadTs: job.payload.threadTs,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
await updateJob(job.id, { runId: followUp.runId }, stateRootDir);
|
|
243
|
+
await assertRunCompleteForDelivery(followUp.runId, rootDir);
|
|
244
|
+
|
|
245
|
+
const delivery = await updateGitHubPullRequestFromRun(followUp.runId, {
|
|
246
|
+
rootDir,
|
|
247
|
+
branchName: pr.branchName,
|
|
248
|
+
prNumber: pr.number,
|
|
249
|
+
exec,
|
|
250
|
+
});
|
|
251
|
+
await copyRunRecord(followUp.runId, rootDir, stateRootDir);
|
|
252
|
+
await writeSlackThreadState({
|
|
253
|
+
...getSlackThreadRef(job),
|
|
254
|
+
runId: followUp.runId,
|
|
255
|
+
parentRunId: pr.runId,
|
|
256
|
+
pullRequest: {
|
|
257
|
+
number: pr.number,
|
|
258
|
+
url: threadState.pullRequest.url,
|
|
259
|
+
branchName: pr.branchName,
|
|
260
|
+
},
|
|
261
|
+
}, stateRootDir);
|
|
262
|
+
|
|
263
|
+
await postSlackMessage({
|
|
264
|
+
channel: job.payload.channel,
|
|
265
|
+
threadTs: job.payload.threadTs,
|
|
266
|
+
text: `Assembly updated PR #${pr.number} with run ${followUp.runId}.`,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
parentRunId: pr.runId,
|
|
271
|
+
followUpRunId: followUp.runId,
|
|
272
|
+
pullRequest: {
|
|
273
|
+
number: pr.number,
|
|
274
|
+
url: threadState.pullRequest.url,
|
|
275
|
+
branchName: pr.branchName,
|
|
276
|
+
},
|
|
277
|
+
delivery,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function assertRunCompleteForDelivery(runId, rootDir) {
|
|
282
|
+
const run = await readRun(runId, rootDir);
|
|
283
|
+
if (run.state.status === "complete") {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const failure = summarizeRunFailure(run);
|
|
288
|
+
throw new Error([
|
|
289
|
+
`run ${runId} finished with status ${run.state.status}; no pull request was created or updated.`,
|
|
290
|
+
failure ? `Failed task: ${failure.taskId}` : null,
|
|
291
|
+
failure?.summary ? `Reason: ${failure.summary}` : null,
|
|
292
|
+
failure?.risks?.length > 0 ? `Details: ${failure.risks.join("; ")}` : null,
|
|
293
|
+
].filter(Boolean).join("\n"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function summarizeRunFailure(run) {
|
|
297
|
+
return [...run.events].reverse().find((event) => ["task.failed", "task.blocked"].includes(event.type))?.data ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function getSlackThreadRef(job) {
|
|
301
|
+
return {
|
|
302
|
+
teamId: job.payload.teamId,
|
|
303
|
+
channel: job.payload.channel,
|
|
304
|
+
threadTs: job.payload.threadTs,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function extractPullRequestNumber(url) {
|
|
309
|
+
const match = String(url ?? "").match(/\/pull\/(\d+)(?:\b|$)/);
|
|
310
|
+
return match ? Number(match[1]) : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function processGitHubPrFeedbackJob(job, { rootDir, stateRootDir, exec, agentRunner }) {
|
|
314
|
+
const pr = await getGitHubPullRequest(job.payload.prNumber, { rootDir, exec });
|
|
315
|
+
if (!pr.runId) {
|
|
316
|
+
throw new Error(`pull request ${job.payload.prNumber} does not include Assembly run metadata`);
|
|
317
|
+
}
|
|
318
|
+
await updateJob(job.id, { parentRunId: pr.runId }, stateRootDir);
|
|
319
|
+
|
|
320
|
+
await exec("git", ["fetch", "origin", pr.branchName], { cwd: rootDir });
|
|
321
|
+
await exec("git", ["checkout", pr.branchName], { cwd: rootDir });
|
|
322
|
+
await exec("git", ["pull", "--ff-only"], { cwd: rootDir });
|
|
323
|
+
await copyRunRecord(pr.runId, stateRootDir, rootDir);
|
|
324
|
+
|
|
325
|
+
const followUp = await createFollowUpRun(pr.runId, job.payload.feedback, {
|
|
326
|
+
rootDir,
|
|
327
|
+
agentRunner,
|
|
328
|
+
source: {
|
|
329
|
+
provider: "github",
|
|
330
|
+
kind: job.payload.kind,
|
|
331
|
+
id: job.payload.commentId ?? job.payload.reviewId,
|
|
332
|
+
url: job.payload.commentUrl,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
await updateJob(job.id, { runId: followUp.runId }, stateRootDir);
|
|
336
|
+
|
|
337
|
+
const delivery = await updateGitHubPullRequestFromRun(followUp.runId, {
|
|
338
|
+
rootDir,
|
|
339
|
+
branchName: pr.branchName,
|
|
340
|
+
prNumber: pr.number,
|
|
341
|
+
commentUrl: job.payload.commentUrl,
|
|
342
|
+
exec,
|
|
343
|
+
});
|
|
344
|
+
await copyRunRecord(followUp.runId, rootDir, stateRootDir);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
parentRunId: pr.runId,
|
|
348
|
+
followUpRunId: followUp.runId,
|
|
349
|
+
delivery,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function processGitHubIssueRequestJob(job, { rootDir, stateRootDir, exec, agentRunner }) {
|
|
354
|
+
const request = [
|
|
355
|
+
`GitHub issue #${job.payload.issueNumber}: ${job.payload.issueTitle}`,
|
|
356
|
+
job.payload.issueBody,
|
|
357
|
+
job.payload.feedback,
|
|
358
|
+
].filter(Boolean).join("\n\n");
|
|
359
|
+
|
|
360
|
+
const run = await createRun(request, {
|
|
361
|
+
rootDir,
|
|
362
|
+
agentRunner,
|
|
363
|
+
metadata: {
|
|
364
|
+
source: {
|
|
365
|
+
provider: "github",
|
|
366
|
+
kind: job.payload.kind,
|
|
367
|
+
issueNumber: job.payload.issueNumber,
|
|
368
|
+
url: job.payload.url,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await updateJob(job.id, { runId: run.runId }, stateRootDir);
|
|
374
|
+
await copyRunRecord(run.runId, rootDir, stateRootDir);
|
|
375
|
+
const pr = await createGitHubPullRequest(run.runId, { rootDir, exec });
|
|
376
|
+
await copyRunRecord(run.runId, rootDir, stateRootDir);
|
|
377
|
+
await exec("gh", [
|
|
378
|
+
"issue",
|
|
379
|
+
"comment",
|
|
380
|
+
String(job.payload.issueNumber),
|
|
381
|
+
"--body",
|
|
382
|
+
`Assembly created ${pr.url} for run ${run.runId}.`,
|
|
383
|
+
], { cwd: rootDir });
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
runId: run.runId,
|
|
387
|
+
issueNumber: job.payload.issueNumber,
|
|
388
|
+
pullRequest: pr,
|
|
389
|
+
};
|
|
390
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const TaskStatus = Object.freeze({
|
|
2
|
+
Pending: "pending",
|
|
3
|
+
InProgress: "in_progress",
|
|
4
|
+
Blocked: "blocked",
|
|
5
|
+
Complete: "complete",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export function createTask({
|
|
9
|
+
id,
|
|
10
|
+
title,
|
|
11
|
+
owner,
|
|
12
|
+
description,
|
|
13
|
+
files = [],
|
|
14
|
+
scope = { paths: files, allowlist: [], denylist: [".env", ".env.*", ".git/"] },
|
|
15
|
+
changePolicy = "modify",
|
|
16
|
+
dependencies = [],
|
|
17
|
+
acceptanceCriteria = [],
|
|
18
|
+
agentProfileId = null,
|
|
19
|
+
status = TaskStatus.Pending,
|
|
20
|
+
}) {
|
|
21
|
+
const task = {
|
|
22
|
+
id,
|
|
23
|
+
title,
|
|
24
|
+
owner,
|
|
25
|
+
description,
|
|
26
|
+
files,
|
|
27
|
+
scope,
|
|
28
|
+
changePolicy,
|
|
29
|
+
dependencies,
|
|
30
|
+
acceptanceCriteria,
|
|
31
|
+
status,
|
|
32
|
+
};
|
|
33
|
+
if (agentProfileId) {
|
|
34
|
+
task.agentProfileId = agentProfileId;
|
|
35
|
+
}
|
|
36
|
+
return task;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createExecutionPlan({
|
|
40
|
+
request,
|
|
41
|
+
summary,
|
|
42
|
+
tasks = [],
|
|
43
|
+
agentProfiles = [],
|
|
44
|
+
risks = [],
|
|
45
|
+
verification = [],
|
|
46
|
+
}) {
|
|
47
|
+
return {
|
|
48
|
+
request,
|
|
49
|
+
summary,
|
|
50
|
+
tasks,
|
|
51
|
+
agentProfiles,
|
|
52
|
+
risks,
|
|
53
|
+
verification,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { getOpenAIConfig } from "./config.js";
|
|
2
|
+
import { buildTaskContext } from "./context-builder.js";
|
|
3
|
+
|
|
4
|
+
const RESULT_SCHEMA = {
|
|
5
|
+
type: "object",
|
|
6
|
+
additionalProperties: false,
|
|
7
|
+
required: ["taskId", "status", "summary", "changedFiles", "artifacts", "risks", "patch", "fileUpdates"],
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: "string" },
|
|
10
|
+
status: { type: "string", enum: ["complete", "blocked", "failed"] },
|
|
11
|
+
summary: { type: "string" },
|
|
12
|
+
changedFiles: {
|
|
13
|
+
type: "array",
|
|
14
|
+
items: { type: "string" },
|
|
15
|
+
},
|
|
16
|
+
artifacts: {
|
|
17
|
+
type: "array",
|
|
18
|
+
items: { type: "string" },
|
|
19
|
+
},
|
|
20
|
+
risks: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "string" },
|
|
23
|
+
},
|
|
24
|
+
patch: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Unified diff patch to apply. Empty string if no code changes are needed.",
|
|
27
|
+
},
|
|
28
|
+
fileUpdates: {
|
|
29
|
+
type: "array",
|
|
30
|
+
description: "Full file replacements to write. Prefer this over patch when editing scoped files.",
|
|
31
|
+
items: {
|
|
32
|
+
type: "object",
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
required: ["path", "content"],
|
|
35
|
+
properties: {
|
|
36
|
+
path: { type: "string" },
|
|
37
|
+
content: { type: "string" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createOpenAIAgentRunner(config = getOpenAIConfig()) {
|
|
45
|
+
return async function runOpenAIAgent(task, runContext = {}) {
|
|
46
|
+
const rootDir = runContext.rootDir ?? process.cwd();
|
|
47
|
+
const taskContext = await buildTaskContext(task, rootDir);
|
|
48
|
+
const agentProfile = runContext.plan?.agentProfiles?.find((profile) => profile.id === task.agentProfileId);
|
|
49
|
+
const response = await fetch(`${config.baseUrl}/responses`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: config.model,
|
|
57
|
+
input: [
|
|
58
|
+
{
|
|
59
|
+
role: "developer",
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: "input_text",
|
|
63
|
+
text:
|
|
64
|
+
"You are an Assembly task agent. Return only JSON matching the supplied schema. " +
|
|
65
|
+
"You handle implementation tasks only. You may only edit files inside the task scope. " +
|
|
66
|
+
"If task.agentProfileId is present, follow the matching agentProfile instructions as the task's dynamic delegation contract. " +
|
|
67
|
+
"Prefer fileUpdates for edits: return the full replacement content for each changed file. " +
|
|
68
|
+
"For fileUpdates, preserve all unrelated existing content exactly and make the smallest requested edit. " +
|
|
69
|
+
"When task.changePolicy is additive, the updated file must keep every existing line in the same order and only insert new lines. " +
|
|
70
|
+
"Do not rewrite, summarize, restructure, or replace a whole file with new documentation unless explicitly requested. " +
|
|
71
|
+
"Use patch only if you can produce a complete unified diff that applies cleanly with git apply from the repository root. " +
|
|
72
|
+
"Never use placeholder hunks or ellipses. Include every changed file in changedFiles. " +
|
|
73
|
+
"Include result.json in artifacts, and include file-updates.json when fileUpdates is non-empty. " +
|
|
74
|
+
"If you do not have enough context to safely edit, return status blocked with an empty patch.",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
role: "user",
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "input_text",
|
|
83
|
+
text: JSON.stringify(
|
|
84
|
+
{
|
|
85
|
+
request: runContext.plan?.request,
|
|
86
|
+
task,
|
|
87
|
+
agentProfile,
|
|
88
|
+
verification: runContext.plan?.verification ?? [],
|
|
89
|
+
scopedFiles: taskContext.files,
|
|
90
|
+
contextLimits: taskContext.limits,
|
|
91
|
+
},
|
|
92
|
+
null,
|
|
93
|
+
2,
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
text: {
|
|
100
|
+
format: {
|
|
101
|
+
type: "json_schema",
|
|
102
|
+
name: "assembly_task_result",
|
|
103
|
+
strict: true,
|
|
104
|
+
schema: RESULT_SCHEMA,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const payload = await response.json().catch(() => null);
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(formatOpenAIError(response.status, payload));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parseStructuredResult(payload);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseStructuredResult(payload) {
|
|
120
|
+
const outputText = payload?.output_text ?? findOutputText(payload);
|
|
121
|
+
if (!outputText) {
|
|
122
|
+
throw new Error("OpenAI response did not include structured output text");
|
|
123
|
+
}
|
|
124
|
+
return JSON.parse(outputText);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findOutputText(payload) {
|
|
128
|
+
for (const item of payload?.output ?? []) {
|
|
129
|
+
for (const content of item.content ?? []) {
|
|
130
|
+
if (content.type === "output_text" && typeof content.text === "string") {
|
|
131
|
+
return content.text;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatOpenAIError(status, payload) {
|
|
139
|
+
const message = payload?.error?.message ?? "unknown OpenAI API error";
|
|
140
|
+
return `OpenAI API request failed with status ${status}: ${message}`;
|
|
141
|
+
}
|