agent-dealer 0.1.1 → 0.1.3
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/bundle/server/dist/db/index.js +5 -0
- package/bundle/server/dist/db/schema.sql +2 -0
- package/bundle/server/dist/queue/answers.test.js +117 -0
- package/bundle/server/dist/queue/dispatcher.js +164 -2
- package/bundle/server/dist/queue/plan-gate.test.js +55 -0
- package/bundle/server/dist/repository/agents.js +14 -5
- package/bundle/server/dist/repository/runs.js +46 -1
- package/bundle/server/dist/routes/index.js +51 -8
- package/bundle/server/dist/runners/claude.js +13 -18
- package/bundle/server/dist/runners/persist.js +17 -4
- package/bundle/server/dist/runners/prompts.js +52 -1
- package/bundle/server/dist/runners/prompts.test.js +63 -0
- package/bundle/server/dist/runners/reflect.js +1 -1
- package/bundle/server/dist/runners/stream-json.js +28 -0
- package/bundle/server/dist/runners/stream-json.test.js +57 -0
- package/bundle/server/dist/usage-summary.js +9 -4
- package/bundle/server/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CH7u5bXC.js +78 -0
- package/bundle/server/static-ui/assets/index-CKuYOeDN.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +100 -0
- package/bundle/shared/dist/agents.js +9 -0
- package/bundle/shared/dist/budget.d.ts +92 -0
- package/bundle/shared/dist/budget.js +108 -0
- package/bundle/shared/dist/index.d.ts +556 -86
- package/bundle/shared/dist/index.js +23 -0
- package/bundle/shared/dist/plan-triage.d.ts +317 -0
- package/bundle/shared/dist/plan-triage.js +65 -0
- package/bundle/shared/dist/plan-triage.test.d.ts +1 -0
- package/bundle/shared/dist/plan-triage.test.js +66 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BPfTEO0-.js +0 -77
- package/bundle/server/static-ui/assets/index-Blmq-P5I.css +0 -1
|
@@ -45,6 +45,11 @@ export function migrate() {
|
|
|
45
45
|
db.exec("ALTER TABLE agents ADD COLUMN default_plan_model TEXT");
|
|
46
46
|
db.exec("ALTER TABLE agents ADD COLUMN default_execute_model TEXT");
|
|
47
47
|
}
|
|
48
|
+
const agentCols3 = db.prepare("PRAGMA table_info(agents)").all();
|
|
49
|
+
if (!agentCols3.some((c) => c.name === "default_plan_budget_json")) {
|
|
50
|
+
db.exec("ALTER TABLE agents ADD COLUMN default_plan_budget_json TEXT");
|
|
51
|
+
db.exec("ALTER TABLE agents ADD COLUMN default_execute_budget_json TEXT");
|
|
52
|
+
}
|
|
48
53
|
const runCols3 = db.prepare("PRAGMA table_info(runs)").all();
|
|
49
54
|
if (!runCols3.some((c) => c.name === "plan_model")) {
|
|
50
55
|
db.exec("ALTER TABLE runs ADD COLUMN plan_model TEXT");
|
|
@@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
|
|
8
8
|
workspace_root TEXT,
|
|
9
9
|
default_plan_model TEXT,
|
|
10
10
|
default_execute_model TEXT,
|
|
11
|
+
default_plan_budget_json TEXT,
|
|
12
|
+
default_execute_budget_json TEXT,
|
|
11
13
|
is_builtin INTEGER NOT NULL DEFAULT 0,
|
|
12
14
|
created_at TEXT NOT NULL,
|
|
13
15
|
updated_at TEXT NOT NULL
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-answers-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, getRun, markPlanTriageConsumed, updateRunFields } = await import("../repository/runs.js");
|
|
12
|
+
const { submitPlanAnswers } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
{ id: "q2", question: "Ship docs too?", options: [{ label: "Yes" }, { label: "No" }] },
|
|
16
|
+
];
|
|
17
|
+
before(() => {
|
|
18
|
+
migrate();
|
|
19
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
20
|
+
});
|
|
21
|
+
function seedQuestionRun() {
|
|
22
|
+
const run = createRun({
|
|
23
|
+
title: "Answers test",
|
|
24
|
+
taskCategory: "other",
|
|
25
|
+
status: "plan_pending",
|
|
26
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
27
|
+
});
|
|
28
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan", sessionId: "sess-1" }, "agent");
|
|
29
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, sessionId: "sess-1", parseFallback: false, consumed: false }, "agent");
|
|
30
|
+
return getRun(run.id);
|
|
31
|
+
}
|
|
32
|
+
test("all-structured answers approve and mark plan_answers approved", () => {
|
|
33
|
+
const run = seedQuestionRun();
|
|
34
|
+
const res = submitPlanAnswers(run.id, {
|
|
35
|
+
answers: [
|
|
36
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
37
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
assert.equal(res.ok, true);
|
|
41
|
+
assert.equal(res.ok && res.outcome, "approved");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
43
|
+
const ans = getLatestArtifact(run.id, "plan_answers");
|
|
44
|
+
assert.match(ans.contentJson, /"outcome":"approved"/);
|
|
45
|
+
assert.equal(getLatestArtifact(run.id, "approved_plan").author, "human");
|
|
46
|
+
const triage = getLatestArtifact(run.id, "plan_triage");
|
|
47
|
+
assert.match(triage.contentJson, /"consumed":true/);
|
|
48
|
+
});
|
|
49
|
+
test("executeModel null does not wipe seeded execute_model", () => {
|
|
50
|
+
const run = seedQuestionRun();
|
|
51
|
+
updateRunFields(run.id, { execute_model: "claude-sonnet-4" });
|
|
52
|
+
const res = submitPlanAnswers(run.id, {
|
|
53
|
+
answers: [
|
|
54
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
55
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
56
|
+
],
|
|
57
|
+
executeModel: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(res.ok, true);
|
|
60
|
+
assert.equal(getRun(run.id).executeModel, "claude-sonnet-4");
|
|
61
|
+
});
|
|
62
|
+
test("free-form answer schedules a redraft instead of approving", () => {
|
|
63
|
+
const run = seedQuestionRun();
|
|
64
|
+
let redrafted = false;
|
|
65
|
+
const res = submitPlanAnswers(run.id, {
|
|
66
|
+
answers: [
|
|
67
|
+
{ questionId: "q1", freeText: "Do it a third way" },
|
|
68
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
69
|
+
],
|
|
70
|
+
}, { onRedraft: () => { redrafted = true; } });
|
|
71
|
+
assert.equal(res.ok && res.outcome, "redraft");
|
|
72
|
+
assert.equal(redrafted, true);
|
|
73
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
74
|
+
});
|
|
75
|
+
test("answers must cover every open question exactly once", () => {
|
|
76
|
+
const run = seedQuestionRun();
|
|
77
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
78
|
+
assert.equal(res.ok, false);
|
|
79
|
+
assert.equal(!res.ok && res.code, 400);
|
|
80
|
+
});
|
|
81
|
+
test("double submit returns 409", () => {
|
|
82
|
+
const run = seedQuestionRun();
|
|
83
|
+
submitPlanAnswers(run.id, {
|
|
84
|
+
answers: [
|
|
85
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
86
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const again = submitPlanAnswers(run.id, {
|
|
90
|
+
answers: [
|
|
91
|
+
{ questionId: "q1", selectedLabel: "B" },
|
|
92
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
assert.equal(!again.ok && again.code, 409);
|
|
96
|
+
});
|
|
97
|
+
test("run without open questions returns 409", () => {
|
|
98
|
+
const run = createRun({
|
|
99
|
+
title: "No questions",
|
|
100
|
+
taskCategory: "other",
|
|
101
|
+
status: "plan_pending",
|
|
102
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
103
|
+
});
|
|
104
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
105
|
+
assert.equal(!res.ok && res.code, 409);
|
|
106
|
+
});
|
|
107
|
+
test("consumed triage rejects answers", () => {
|
|
108
|
+
const run = seedQuestionRun();
|
|
109
|
+
markPlanTriageConsumed(run.id);
|
|
110
|
+
const res = submitPlanAnswers(run.id, {
|
|
111
|
+
answers: [
|
|
112
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
113
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
assert.equal(!res.ok && res.code, 409);
|
|
117
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { planGateDecision } from "@agent-dealer/shared";
|
|
2
|
+
import { buildPlanRevisePrompt } from "../runners/prompts.js";
|
|
3
|
+
import { addArtifact, countByStatus, getLatestArtifact, getRun, listArtifacts, listRuns, markPlanTriageConsumed, patchRunPhaseBudget, transitionRun, updateRunFields, } from "../repository/runs.js";
|
|
2
4
|
import { runAgent } from "../runners/claude.js";
|
|
3
5
|
import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.js";
|
|
4
6
|
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
@@ -42,6 +44,24 @@ function isReadyForPlanReview(run) {
|
|
|
42
44
|
!!getLatestArtifact(run.id, "draft_plan") &&
|
|
43
45
|
!activePlanDrafts.has(run.id));
|
|
44
46
|
}
|
|
47
|
+
/** Open (unanswered, unconsumed) question count for a plan_pending run. */
|
|
48
|
+
function openQuestionCount(runId) {
|
|
49
|
+
const tri = getLatestArtifact(runId, "plan_triage");
|
|
50
|
+
if (!tri?.contentJson)
|
|
51
|
+
return 0;
|
|
52
|
+
try {
|
|
53
|
+
const c = JSON.parse(tri.contentJson);
|
|
54
|
+
if (c.consumed || !c.questions?.length)
|
|
55
|
+
return 0;
|
|
56
|
+
const ans = getLatestArtifact(runId, "plan_answers");
|
|
57
|
+
if (ans && ans.createdAt > tri.createdAt)
|
|
58
|
+
return 0;
|
|
59
|
+
return c.questions.length;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
45
65
|
export function getSnapshot() {
|
|
46
66
|
const all = listRuns();
|
|
47
67
|
const runningRuns = all.filter((r) => r.status === "running");
|
|
@@ -57,6 +77,16 @@ export function getSnapshot() {
|
|
|
57
77
|
const awaitingPlanReview = planningPhase.filter(isReadyForPlanReview);
|
|
58
78
|
const planningActiveRuns = planningPhase.filter((r) => activePlanDrafts.has(r.id));
|
|
59
79
|
const planningQueuedRuns = planningPhase.filter((r) => !activePlanDrafts.has(r.id) && !isReadyForPlanReview(r));
|
|
80
|
+
const openQuestionCounts = {};
|
|
81
|
+
for (const run of awaitingPlanReview) {
|
|
82
|
+
const n = openQuestionCount(run.id);
|
|
83
|
+
if (n > 0)
|
|
84
|
+
openQuestionCounts[run.id] = n;
|
|
85
|
+
}
|
|
86
|
+
const awaitingAnswerRuns = awaitingPlanReview.filter((r) => openQuestionCounts[r.id]);
|
|
87
|
+
const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
|
|
88
|
+
.filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
|
|
89
|
+
.map((r) => r.id);
|
|
60
90
|
return {
|
|
61
91
|
planReviewCount: awaitingPlanReview.length,
|
|
62
92
|
resultReviewCount: resultReviewRuns.length,
|
|
@@ -68,6 +98,9 @@ export function getSnapshot() {
|
|
|
68
98
|
resultReviewRuns,
|
|
69
99
|
recentDone,
|
|
70
100
|
awaitingPlanReview,
|
|
101
|
+
awaitingAnswerRuns,
|
|
102
|
+
openQuestionCounts,
|
|
103
|
+
autoApprovedRunIds,
|
|
71
104
|
runs: all,
|
|
72
105
|
agentDeckOnline: false,
|
|
73
106
|
agents: [],
|
|
@@ -195,6 +228,49 @@ export async function kickRun(run) {
|
|
|
195
228
|
}
|
|
196
229
|
await dispatch();
|
|
197
230
|
}
|
|
231
|
+
function priorQuestionRounds(runId, currentTriageArtifactId) {
|
|
232
|
+
return listArtifacts(runId).filter((a) => {
|
|
233
|
+
if (a.kind !== "plan_triage" || a.id === currentTriageArtifactId || !a.contentJson)
|
|
234
|
+
return false;
|
|
235
|
+
try {
|
|
236
|
+
const c = JSON.parse(a.contentJson);
|
|
237
|
+
return (c.questions?.length ?? 0) > 0;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}).length;
|
|
243
|
+
}
|
|
244
|
+
/** Self-triage gate after a plan draft persists (PRD F2). Exported for tests. */
|
|
245
|
+
export function applyPlanGate(run) {
|
|
246
|
+
const art = getLatestArtifact(run.id, "plan_triage");
|
|
247
|
+
if (!art?.contentJson)
|
|
248
|
+
return "await_review";
|
|
249
|
+
let triage;
|
|
250
|
+
try {
|
|
251
|
+
triage = JSON.parse(art.contentJson);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return "await_review";
|
|
255
|
+
}
|
|
256
|
+
const decision = planGateDecision({
|
|
257
|
+
triage,
|
|
258
|
+
priorQuestionRounds: priorQuestionRounds(run.id, art.id),
|
|
259
|
+
});
|
|
260
|
+
if (decision !== "auto_approve")
|
|
261
|
+
return decision;
|
|
262
|
+
const fresh = getRun(run.id);
|
|
263
|
+
if (!fresh || fresh.status !== "plan_pending")
|
|
264
|
+
return "await_review";
|
|
265
|
+
const draft = getLatestArtifact(run.id, "draft_plan");
|
|
266
|
+
if (!draft?.contentJson)
|
|
267
|
+
return "await_review";
|
|
268
|
+
const plan = JSON.parse(draft.contentJson);
|
|
269
|
+
addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
|
|
270
|
+
transitionRun(run.id, "plan_approved");
|
|
271
|
+
void forceDispatch();
|
|
272
|
+
return "auto_approve";
|
|
273
|
+
}
|
|
198
274
|
export async function draftPlan(run, opts) {
|
|
199
275
|
if (run.status === "queued") {
|
|
200
276
|
transitionRun(run.id, "plan_pending");
|
|
@@ -208,7 +284,7 @@ export async function draftPlan(run, opts) {
|
|
|
208
284
|
}
|
|
209
285
|
const rt = runtimeFor(updated);
|
|
210
286
|
try {
|
|
211
|
-
const result = await runAgent(updated, "plan");
|
|
287
|
+
const result = await runAgent(updated, "plan", opts?.revise);
|
|
212
288
|
const persisted = persistRunOutput({
|
|
213
289
|
run: updated,
|
|
214
290
|
phase: "plan",
|
|
@@ -225,6 +301,18 @@ export async function draftPlan(run, opts) {
|
|
|
225
301
|
}
|
|
226
302
|
if (persisted.planMarkdown) {
|
|
227
303
|
addArtifact(updated.id, "draft_plan", { markdown: persisted.planMarkdown, sessionId: persisted.sessionId }, "agent", result.logPath);
|
|
304
|
+
const triage = persisted.planTriage;
|
|
305
|
+
if (triage) {
|
|
306
|
+
addArtifact(updated.id, "plan_triage", {
|
|
307
|
+
verdict: triage.verdict,
|
|
308
|
+
rationale: triage.rationale,
|
|
309
|
+
questions: triage.questions,
|
|
310
|
+
sessionId: persisted.sessionId,
|
|
311
|
+
parseFallback: triage.parseFallback,
|
|
312
|
+
consumed: false,
|
|
313
|
+
}, "agent");
|
|
314
|
+
applyPlanGate(getRun(run.id));
|
|
315
|
+
}
|
|
228
316
|
}
|
|
229
317
|
else if (!opts?.replace) {
|
|
230
318
|
addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
|
|
@@ -245,6 +333,80 @@ export async function draftPlan(run, opts) {
|
|
|
245
333
|
await notify();
|
|
246
334
|
return getRun(run.id);
|
|
247
335
|
}
|
|
336
|
+
/** Answer open plan questions (PRD F3). Structured answers approve + dispatch; free-form revises the plan. */
|
|
337
|
+
export function submitPlanAnswers(runId, input, opts) {
|
|
338
|
+
const run = getRun(runId);
|
|
339
|
+
if (!run)
|
|
340
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
341
|
+
if (run.status !== "plan_pending") {
|
|
342
|
+
return { ok: false, code: 409, error: `No open questions — run is ${run.status}` };
|
|
343
|
+
}
|
|
344
|
+
const triageArt = getLatestArtifact(runId, "plan_triage");
|
|
345
|
+
if (!triageArt?.contentJson)
|
|
346
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
347
|
+
let triage;
|
|
348
|
+
try {
|
|
349
|
+
triage = JSON.parse(triageArt.contentJson);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
353
|
+
}
|
|
354
|
+
if (triage.consumed || triage.questions.length === 0) {
|
|
355
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
356
|
+
}
|
|
357
|
+
const priorAnswers = getLatestArtifact(runId, "plan_answers");
|
|
358
|
+
if (priorAnswers && priorAnswers.createdAt > triageArt.createdAt) {
|
|
359
|
+
return { ok: false, code: 409, error: "Questions already answered" };
|
|
360
|
+
}
|
|
361
|
+
const expected = triage.questions.map((q) => q.id).sort().join(",");
|
|
362
|
+
const got = input.answers.map((a) => a.questionId).sort().join(",");
|
|
363
|
+
if (expected !== got) {
|
|
364
|
+
return { ok: false, code: 400, error: "Answers must cover every open question exactly once" };
|
|
365
|
+
}
|
|
366
|
+
const freeForm = input.answers.some((a) => a.freeText);
|
|
367
|
+
const outcome = freeForm ? "redraft" : "approved";
|
|
368
|
+
addArtifact(runId, "plan_answers", { answers: input.answers, outcome, answeredAt: new Date().toISOString() }, "human");
|
|
369
|
+
if (!freeForm) {
|
|
370
|
+
// null/empty = "Default" — do not wipe a seeded execute_model; only set explicit picks
|
|
371
|
+
if (input.executeModel?.trim()) {
|
|
372
|
+
updateRunFields(runId, { execute_model: input.executeModel.trim() });
|
|
373
|
+
}
|
|
374
|
+
if (input.executeBudget !== undefined) {
|
|
375
|
+
patchRunPhaseBudget(runId, "execute", input.executeBudget);
|
|
376
|
+
}
|
|
377
|
+
markPlanTriageConsumed(runId);
|
|
378
|
+
try {
|
|
379
|
+
transitionRun(runId, "plan_approved");
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
return { ok: false, code: 409, error: String(e) };
|
|
383
|
+
}
|
|
384
|
+
const draft = getLatestArtifact(runId, "draft_plan");
|
|
385
|
+
const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
|
|
386
|
+
addArtifact(runId, "approved_plan", plan, "human");
|
|
387
|
+
void forceDispatch();
|
|
388
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
389
|
+
}
|
|
390
|
+
(opts?.onRedraft ?? scheduleRevisePlan)(getRun(runId), triage, input.answers);
|
|
391
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
392
|
+
}
|
|
393
|
+
function scheduleRevisePlan(run, triage, answers) {
|
|
394
|
+
if (activePlanDrafts.has(run.id))
|
|
395
|
+
return;
|
|
396
|
+
activePlanDrafts.add(run.id);
|
|
397
|
+
void draftPlan(run, {
|
|
398
|
+
replace: true,
|
|
399
|
+
revise: {
|
|
400
|
+
resumeSessionId: triage.sessionId,
|
|
401
|
+
prompt: buildPlanRevisePrompt(run, triage.questions, answers),
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
.catch((e) => console.error("[plan-revise]", run.id, e))
|
|
405
|
+
.finally(() => {
|
|
406
|
+
activePlanDrafts.delete(run.id);
|
|
407
|
+
notify().catch(console.error);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
248
410
|
/** Fire-and-forget: human requested a new agent plan (replaces draft). */
|
|
249
411
|
export function scheduleRedraft(run) {
|
|
250
412
|
if (activePlanDrafts.has(run.id))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gate-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, getRun } = await import("../repository/runs.js");
|
|
12
|
+
const { applyPlanGate, getSnapshot } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
];
|
|
16
|
+
before(() => {
|
|
17
|
+
migrate();
|
|
18
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
19
|
+
});
|
|
20
|
+
function seedRun(triage) {
|
|
21
|
+
const run = createRun({
|
|
22
|
+
title: "Gate test",
|
|
23
|
+
taskCategory: "other",
|
|
24
|
+
status: "plan_pending",
|
|
25
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
26
|
+
});
|
|
27
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
28
|
+
addArtifact(run.id, "plan_triage", triage, "agent");
|
|
29
|
+
return getRun(run.id);
|
|
30
|
+
}
|
|
31
|
+
test("trivial triage auto-approves: system approved_plan + plan_approved status", () => {
|
|
32
|
+
const run = seedRun({ verdict: "trivial", rationale: "tiny", questions: [], parseFallback: false, consumed: false });
|
|
33
|
+
assert.equal(applyPlanGate(run), "auto_approve");
|
|
34
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
35
|
+
const approved = getLatestArtifact(run.id, "approved_plan");
|
|
36
|
+
assert.equal(approved.author, "system");
|
|
37
|
+
assert.match(approved.contentJson, /tiny/);
|
|
38
|
+
});
|
|
39
|
+
test("questions await answers and leave status untouched", () => {
|
|
40
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
41
|
+
assert.equal(applyPlanGate(run), "await_answers");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
43
|
+
});
|
|
44
|
+
test("third question round goes to manual review", () => {
|
|
45
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r1", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
46
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r2", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
47
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r3", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
48
|
+
assert.equal(applyPlanGate(getRun(run.id)), "await_review");
|
|
49
|
+
});
|
|
50
|
+
test("snapshot exposes awaitingAnswerRuns, openQuestionCounts, autoApprovedRunIds", () => {
|
|
51
|
+
const snap = getSnapshot();
|
|
52
|
+
assert.equal(Array.isArray(snap.awaitingAnswerRuns), true);
|
|
53
|
+
assert.equal(typeof snap.openQuestionCounts, "object");
|
|
54
|
+
assert.equal(Array.isArray(snap.autoApprovedRunIds), true);
|
|
55
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serializePhaseBudget } from "@agent-dealer/shared";
|
|
1
2
|
import { v4 as uuid } from "uuid";
|
|
2
3
|
import { getDb } from "../db/index.js";
|
|
3
4
|
function rowToAgent(row) {
|
|
@@ -11,6 +12,8 @@ function rowToAgent(row) {
|
|
|
11
12
|
playbookId: row.playbook_id,
|
|
12
13
|
defaultPlanModel: row.default_plan_model,
|
|
13
14
|
defaultExecuteModel: row.default_execute_model,
|
|
15
|
+
defaultPlanBudgetJson: row.default_plan_budget_json,
|
|
16
|
+
defaultExecuteBudgetJson: row.default_execute_budget_json,
|
|
14
17
|
isBuiltin: row.is_builtin === 1,
|
|
15
18
|
createdAt: row.created_at,
|
|
16
19
|
updatedAt: row.updated_at,
|
|
@@ -31,9 +34,9 @@ export function createAgent(input, deckName) {
|
|
|
31
34
|
const now = new Date().toISOString();
|
|
32
35
|
const id = uuid();
|
|
33
36
|
db.prepare(`
|
|
34
|
-
INSERT INTO agents (id, name, runtime, deck_id, deck_name, playbook_id, workspace_root, default_plan_model, default_execute_model, is_builtin, created_at, updated_at)
|
|
35
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
36
|
-
`).run(id, input.name.trim(), input.runtime, input.deckId ?? null, deckName ?? null, input.playbookId ?? null, input.workspaceRoot.trim(), input.defaultPlanModel ?? null, input.defaultExecuteModel ?? null, now, now);
|
|
37
|
+
INSERT INTO agents (id, name, runtime, deck_id, deck_name, playbook_id, workspace_root, default_plan_model, default_execute_model, default_plan_budget_json, default_execute_budget_json, is_builtin, created_at, updated_at)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
39
|
+
`).run(id, input.name.trim(), input.runtime, input.deckId ?? null, deckName ?? null, input.playbookId ?? null, input.workspaceRoot.trim(), input.defaultPlanModel ?? null, input.defaultExecuteModel ?? null, serializePhaseBudget(input.defaultPlanBudget), serializePhaseBudget(input.defaultExecuteBudget), now, now);
|
|
37
40
|
return getAgent(id);
|
|
38
41
|
}
|
|
39
42
|
export function updateAgent(id, input, deckName) {
|
|
@@ -48,14 +51,20 @@ export function updateAgent(id, input, deckName) {
|
|
|
48
51
|
const playbookId = input.playbookId !== undefined ? input.playbookId : existing.playbookId;
|
|
49
52
|
const defaultPlanModel = input.defaultPlanModel !== undefined ? input.defaultPlanModel : existing.defaultPlanModel;
|
|
50
53
|
const defaultExecuteModel = input.defaultExecuteModel !== undefined ? input.defaultExecuteModel : existing.defaultExecuteModel;
|
|
54
|
+
const defaultPlanBudgetJson = input.defaultPlanBudget !== undefined
|
|
55
|
+
? serializePhaseBudget(input.defaultPlanBudget)
|
|
56
|
+
: existing.defaultPlanBudgetJson;
|
|
57
|
+
const defaultExecuteBudgetJson = input.defaultExecuteBudget !== undefined
|
|
58
|
+
? serializePhaseBudget(input.defaultExecuteBudget)
|
|
59
|
+
: existing.defaultExecuteBudgetJson;
|
|
51
60
|
const resolvedDeckName = input.deckId !== undefined ? (input.deckId ? (deckName ?? null) : null) : existing.deckName;
|
|
52
61
|
getDb()
|
|
53
62
|
.prepare(`
|
|
54
63
|
UPDATE agents SET name = ?, runtime = ?, deck_id = ?, deck_name = ?, playbook_id = ?, workspace_root = ?,
|
|
55
|
-
default_plan_model = ?, default_execute_model = ?, updated_at = ?
|
|
64
|
+
default_plan_model = ?, default_execute_model = ?, default_plan_budget_json = ?, default_execute_budget_json = ?, updated_at = ?
|
|
56
65
|
WHERE id = ?
|
|
57
66
|
`)
|
|
58
|
-
.run(name, runtime, deckId, resolvedDeckName, playbookId, workspaceRoot, defaultPlanModel, defaultExecuteModel, now, id);
|
|
67
|
+
.run(name, runtime, deckId, resolvedDeckName, playbookId, workspaceRoot, defaultPlanModel, defaultExecuteModel, defaultPlanBudgetJson, defaultExecuteBudgetJson, now, id);
|
|
59
68
|
return getAgent(id);
|
|
60
69
|
}
|
|
61
70
|
export function deleteAgent(id) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CURSOR_DEFAULT_MODEL } from "@agent-dealer/shared";
|
|
2
|
+
import { mergeRunBudget, parsePhaseBudget, parseRunBudget, resolvePhaseBudget, serializeRunBudget, } from "@agent-dealer/shared";
|
|
2
3
|
import { canTransition } from "@agent-dealer/shared";
|
|
3
4
|
import { v4 as uuid } from "uuid";
|
|
4
5
|
import { getDb } from "../db/index.js";
|
|
@@ -74,7 +75,7 @@ export function createRun(input, opts) {
|
|
|
74
75
|
update_ticket_status: "require_approval",
|
|
75
76
|
publish_external: "require_approval",
|
|
76
77
|
}),
|
|
77
|
-
budget_json:
|
|
78
|
+
budget_json: serializeRunBudget(input.budget ?? {}),
|
|
78
79
|
created_at: now,
|
|
79
80
|
updated_at: now,
|
|
80
81
|
};
|
|
@@ -141,6 +142,35 @@ export function listRunsReadyForPlanReview() {
|
|
|
141
142
|
.all();
|
|
142
143
|
return rows.map(rowToRun);
|
|
143
144
|
}
|
|
145
|
+
export function resolveBudgetForPhase(run, phase) {
|
|
146
|
+
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
147
|
+
return resolvePhaseBudget({
|
|
148
|
+
phase,
|
|
149
|
+
runBudget: parseRunBudget(run.budgetJson),
|
|
150
|
+
agentPlanBudget: parsePhaseBudget(agent?.defaultPlanBudgetJson),
|
|
151
|
+
agentExecuteBudget: parsePhaseBudget(agent?.defaultExecuteBudgetJson),
|
|
152
|
+
testMode: process.env.AGENT_DEALER_TEST_BUDGET === "1",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export function updateRunBudget(id, patch) {
|
|
156
|
+
const run = getRun(id);
|
|
157
|
+
if (!run)
|
|
158
|
+
throw new Error(`Run not found: ${id}`);
|
|
159
|
+
const merged = mergeRunBudget(parseRunBudget(run.budgetJson), patch);
|
|
160
|
+
const budgetJson = serializeRunBudget(merged);
|
|
161
|
+
const now = new Date().toISOString();
|
|
162
|
+
getDb().prepare("UPDATE runs SET budget_json = ?, updated_at = ? WHERE id = ?").run(budgetJson, now, id);
|
|
163
|
+
const updated = getRun(id);
|
|
164
|
+
if (!updated)
|
|
165
|
+
throw new Error(`Run vanished: ${id}`);
|
|
166
|
+
return updated;
|
|
167
|
+
}
|
|
168
|
+
export function patchRunPhaseBudget(id, phase, budget) {
|
|
169
|
+
if (budget === undefined)
|
|
170
|
+
return getRun(id);
|
|
171
|
+
// null = clear run-level override for this phase (revert to agent defaults)
|
|
172
|
+
return updateRunBudget(id, { [phase]: budget });
|
|
173
|
+
}
|
|
144
174
|
export function resolveModelForPhase(run, phase) {
|
|
145
175
|
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
146
176
|
const isCursor = run.runtime === "cursor_local";
|
|
@@ -358,6 +388,21 @@ export function getLatestArtifact(runId, kind) {
|
|
|
358
388
|
createdAt: row.created_at,
|
|
359
389
|
};
|
|
360
390
|
}
|
|
391
|
+
/** Latest plan_triage stops auto-approving / accepting answers once a human takes over the plan. */
|
|
392
|
+
export function markPlanTriageConsumed(runId) {
|
|
393
|
+
const art = getLatestArtifact(runId, "plan_triage");
|
|
394
|
+
if (!art?.contentJson)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
const content = JSON.parse(art.contentJson);
|
|
398
|
+
if (content.consumed === true)
|
|
399
|
+
return;
|
|
400
|
+
updateArtifactContent(art.id, { ...content, consumed: true });
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* leave malformed triage untouched */
|
|
404
|
+
}
|
|
405
|
+
}
|
|
361
406
|
const OPS_BOARD_STATUSES = [
|
|
362
407
|
"queued",
|
|
363
408
|
"plan_pending",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
|
|
3
|
-
import {
|
|
2
|
+
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
|
|
3
|
+
import { parseRunBudget } from "@agent-dealer/shared";
|
|
4
|
+
import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
|
|
4
5
|
import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
|
|
5
6
|
import { listAgentsWithHealth } from "../adapters/agent-health.js";
|
|
6
|
-
import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, subscribe, } from "../queue/dispatcher.js";
|
|
7
|
+
import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
|
|
7
8
|
import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
|
|
8
9
|
import { testAgentDeckConnection } from "../adapters/agent-deck.js";
|
|
9
10
|
import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
|
|
@@ -52,11 +53,12 @@ export async function registerRoutes(app) {
|
|
|
52
53
|
const run = getRun(id);
|
|
53
54
|
if (!run)
|
|
54
55
|
return reply.status(404).send({ error: "Not found" });
|
|
56
|
+
const artifacts = listArtifacts(id);
|
|
55
57
|
return {
|
|
56
58
|
run,
|
|
57
|
-
artifacts
|
|
59
|
+
artifacts,
|
|
58
60
|
events: listEvents(id),
|
|
59
|
-
usageSummary: buildLineageUsageSummary(run),
|
|
61
|
+
usageSummary: buildLineageUsageSummary(run, { artifactsByRunId: { [id]: artifacts } }),
|
|
60
62
|
traceSummary: buildLineageTraceSummary(run),
|
|
61
63
|
};
|
|
62
64
|
});
|
|
@@ -233,6 +235,9 @@ export async function registerRoutes(app) {
|
|
|
233
235
|
plan_model: input.planModel !== undefined ? input.planModel : undefined,
|
|
234
236
|
execute_model: input.executeModel !== undefined ? input.executeModel : undefined,
|
|
235
237
|
});
|
|
238
|
+
if (input.budget !== undefined) {
|
|
239
|
+
return updateRunBudget(id, input.budget);
|
|
240
|
+
}
|
|
236
241
|
return updated;
|
|
237
242
|
});
|
|
238
243
|
app.post("/api/runs/:id/draft-plan", async (req, reply) => {
|
|
@@ -247,6 +252,9 @@ export async function registerRoutes(app) {
|
|
|
247
252
|
if (input.planModel !== undefined) {
|
|
248
253
|
updateRunFields(id, { plan_model: input.planModel });
|
|
249
254
|
}
|
|
255
|
+
if (input.planBudget !== undefined) {
|
|
256
|
+
patchRunPhaseBudget(id, "plan", input.planBudget);
|
|
257
|
+
}
|
|
250
258
|
try {
|
|
251
259
|
assertAgentConfigured(getRun(id));
|
|
252
260
|
const scheduled = scheduleRedraft(getRun(id));
|
|
@@ -265,8 +273,6 @@ export async function registerRoutes(app) {
|
|
|
265
273
|
if (!run)
|
|
266
274
|
return reply.status(404).send({ error: "Not found" });
|
|
267
275
|
const input = UpdatePlanInput.parse(req.body);
|
|
268
|
-
const kind = input.approve ? "approved_plan" : "draft_plan";
|
|
269
|
-
addArtifact(id, kind, { markdown: input.planMarkdown }, "human");
|
|
270
276
|
if (input.approve) {
|
|
271
277
|
const hasPlan = listArtifacts(id).some((a) => a.kind === "draft_plan") ||
|
|
272
278
|
input.planMarkdown.trim().length > 0;
|
|
@@ -288,16 +294,46 @@ export async function registerRoutes(app) {
|
|
|
288
294
|
if (Object.keys(patch).length > 0) {
|
|
289
295
|
updateRunFields(id, patch);
|
|
290
296
|
}
|
|
291
|
-
|
|
297
|
+
if (input.planBudget !== undefined) {
|
|
298
|
+
patchRunPhaseBudget(id, "plan", input.planBudget);
|
|
299
|
+
}
|
|
300
|
+
if (input.executeBudget !== undefined) {
|
|
301
|
+
patchRunPhaseBudget(id, "execute", input.executeBudget);
|
|
302
|
+
}
|
|
303
|
+
markPlanTriageConsumed(id);
|
|
304
|
+
try {
|
|
305
|
+
transitionRun(id, "plan_approved");
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
return reply.status(409).send({ error: String(e) });
|
|
309
|
+
}
|
|
310
|
+
addArtifact(id, "approved_plan", { markdown: input.planMarkdown }, "human");
|
|
311
|
+
const updated = getRun(id);
|
|
292
312
|
syncLinearForRun(updated, "plan_approved").catch((e) => console.error("[linear-sync] plan_approved:", e));
|
|
293
313
|
await forceDispatch();
|
|
294
314
|
return updated;
|
|
295
315
|
}
|
|
316
|
+
addArtifact(id, "draft_plan", { markdown: input.planMarkdown }, "human");
|
|
317
|
+
markPlanTriageConsumed(id);
|
|
296
318
|
if (run.status === "queued") {
|
|
297
319
|
return transitionRun(id, "plan_pending");
|
|
298
320
|
}
|
|
299
321
|
return getRun(id);
|
|
300
322
|
});
|
|
323
|
+
app.post("/api/runs/:id/plan/answers", async (req, reply) => {
|
|
324
|
+
const { id } = req.params;
|
|
325
|
+
let input;
|
|
326
|
+
try {
|
|
327
|
+
input = PlanAnswersInput.parse(req.body);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
return reply.status(400).send({ error: String(e) });
|
|
331
|
+
}
|
|
332
|
+
const result = submitPlanAnswers(id, input);
|
|
333
|
+
if (!result.ok)
|
|
334
|
+
return reply.status(result.code).send({ error: result.error });
|
|
335
|
+
return { run: result.run, outcome: result.outcome };
|
|
336
|
+
});
|
|
301
337
|
app.post("/api/runs/:id/kick", async (req, reply) => {
|
|
302
338
|
const { id } = req.params;
|
|
303
339
|
const run = getRun(id);
|
|
@@ -327,6 +363,9 @@ export async function registerRoutes(app) {
|
|
|
327
363
|
return reply.status(400).send({ error: `Cannot kick from status ${run.status}` });
|
|
328
364
|
}
|
|
329
365
|
updateRunFields(id, patch);
|
|
366
|
+
if (input.executeBudget !== undefined) {
|
|
367
|
+
patchRunPhaseBudget(id, "execute", input.executeBudget);
|
|
368
|
+
}
|
|
330
369
|
const updated = getRun(id);
|
|
331
370
|
await kickRun(updated);
|
|
332
371
|
await forceDispatch();
|
|
@@ -369,6 +408,7 @@ export async function registerRoutes(app) {
|
|
|
369
408
|
status: "plan_approved",
|
|
370
409
|
agentId: run.agentId ?? BUILTIN_AGENT_CLAUDE_ID,
|
|
371
410
|
planModel: run.planModel ?? undefined,
|
|
411
|
+
budget: parseRunBudget(run.budgetJson),
|
|
372
412
|
}, { source: run.source, externalId: run.externalId ?? undefined, externalLabel: run.externalLabel ?? undefined, lineageId: run.lineageId ?? run.id });
|
|
373
413
|
updateRunFields(retry.id, {
|
|
374
414
|
deck_id: run.deckId,
|
|
@@ -377,6 +417,9 @@ export async function registerRoutes(app) {
|
|
|
377
417
|
runtime: run.runtime,
|
|
378
418
|
execute_model: input.executeModel ?? input.planModel ?? run.executeModel,
|
|
379
419
|
});
|
|
420
|
+
if (input.executeBudget !== undefined) {
|
|
421
|
+
patchRunPhaseBudget(retry.id, "execute", input.executeBudget);
|
|
422
|
+
}
|
|
380
423
|
try {
|
|
381
424
|
addArtifact(retry.id, "approved_plan", JSON.parse(approvedPlan.contentJson), approvedPlan.author);
|
|
382
425
|
}
|