agent-dealer 0.1.4 → 0.1.5
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/queue/plan-delegation.js +27 -0
- package/bundle/server/dist/queue/plan-delegation.test.js +61 -0
- package/bundle/server/dist/queue/result-qa.js +66 -0
- package/bundle/server/dist/queue/result-qa.test.js +146 -0
- package/bundle/server/dist/repository/result-qa.js +37 -0
- package/bundle/server/dist/routes/index.js +18 -1
- package/bundle/server/dist/runners/claude-args.js +6 -1
- package/bundle/server/dist/runners/claude.js +13 -5
- package/bundle/server/dist/runners/claude.test.js +18 -1
- package/bundle/server/dist/runners/persist.js +1 -1
- package/bundle/server/dist/runners/prompts.js +69 -1
- package/bundle/server/dist/runners/prompts.test.js +80 -1
- package/bundle/server/dist/runners/qa.js +41 -0
- package/bundle/server/dist/runners/run-context.js +15 -0
- package/bundle/server/dist/usage-summary.js +10 -3
- package/bundle/server/dist/usage-summary.test.js +33 -0
- package/bundle/server/package.json +1 -1
- package/bundle/server/static-ui/assets/index-D1y36xc4.js +78 -0
- package/bundle/server/static-ui/assets/index-ukkofc4N.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +68 -67
- package/bundle/shared/dist/index.js +3 -1
- package/bundle/shared/dist/plan-triage.d.ts +4 -4
- package/bundle/shared/dist/plan-triage.js +2 -2
- package/bundle/shared/dist/result-qa.d.ts +50 -0
- package/bundle/shared/dist/result-qa.js +27 -0
- package/bundle/shared/dist/result-qa.test.d.ts +1 -0
- package/bundle/shared/dist/result-qa.test.js +63 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Cc-h06U6.js +0 -78
- package/bundle/server/static-ui/assets/index-DKnME33I.css +0 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { addArtifact, getLatestArtifact } from "../repository/runs.js";
|
|
2
|
+
/**
|
|
3
|
+
* Human approved a plan whose triage questions are still open.
|
|
4
|
+
* Record the delegation so the executor sees the questions it must decide itself.
|
|
5
|
+
* Call BEFORE markPlanTriageConsumed — a consumed triage has nothing to delegate.
|
|
6
|
+
*/
|
|
7
|
+
export function recordPlanDelegation(runId) {
|
|
8
|
+
const triageArt = getLatestArtifact(runId, "plan_triage");
|
|
9
|
+
if (!triageArt?.contentJson)
|
|
10
|
+
return false;
|
|
11
|
+
let triage;
|
|
12
|
+
try {
|
|
13
|
+
triage = JSON.parse(triageArt.contentJson);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (triage.consumed || triage.questions.length === 0)
|
|
19
|
+
return false;
|
|
20
|
+
// >= not >: createdAt has millisecond precision, and answers for this triage
|
|
21
|
+
// round can tie with it. Answers can never legitimately predate their triage.
|
|
22
|
+
const answers = getLatestArtifact(runId, "plan_answers");
|
|
23
|
+
if (answers && answers.createdAt >= triageArt.createdAt)
|
|
24
|
+
return false;
|
|
25
|
+
addArtifact(runId, "plan_answers", { answers: [], outcome: "delegated", answeredAt: new Date().toISOString() }, "human");
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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-delegate-"));
|
|
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, markPlanTriageConsumed } = await import("../repository/runs.js");
|
|
12
|
+
const { recordPlanDelegation } = await import("./plan-delegation.js");
|
|
13
|
+
const QUESTIONS = [{ id: "q1", question: "Which backend?", options: [{ label: "SQLite" }, { label: "Postgres" }] }];
|
|
14
|
+
before(() => {
|
|
15
|
+
migrate();
|
|
16
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
17
|
+
});
|
|
18
|
+
function seedRun(triage) {
|
|
19
|
+
const run = createRun({
|
|
20
|
+
title: "Delegate test",
|
|
21
|
+
taskCategory: "other",
|
|
22
|
+
status: "plan_pending",
|
|
23
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
24
|
+
});
|
|
25
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
26
|
+
if (triage)
|
|
27
|
+
addArtifact(run.id, "plan_triage", triage, "agent");
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
30
|
+
test("open questions produce a delegated plan_answers record", () => {
|
|
31
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
32
|
+
assert.equal(recordPlanDelegation(run.id), true);
|
|
33
|
+
const art = getLatestArtifact(run.id, "plan_answers");
|
|
34
|
+
const content = JSON.parse(art.contentJson);
|
|
35
|
+
assert.equal(content.outcome, "delegated");
|
|
36
|
+
assert.deepEqual(content.answers, []);
|
|
37
|
+
assert.equal(art.author, "human");
|
|
38
|
+
});
|
|
39
|
+
test("no triage means nothing to delegate", () => {
|
|
40
|
+
const run = seedRun();
|
|
41
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
42
|
+
assert.equal(getLatestArtifact(run.id, "plan_answers"), null);
|
|
43
|
+
});
|
|
44
|
+
test("triage without questions means nothing to delegate", () => {
|
|
45
|
+
const run = seedRun({ verdict: "trivial", rationale: "r", questions: [], parseFallback: false, consumed: false });
|
|
46
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
47
|
+
});
|
|
48
|
+
test("consumed triage means nothing to delegate", () => {
|
|
49
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
50
|
+
markPlanTriageConsumed(run.id);
|
|
51
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
52
|
+
});
|
|
53
|
+
test("already-answered questions are not delegated", () => {
|
|
54
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
55
|
+
addArtifact(run.id, "plan_answers", {
|
|
56
|
+
answers: [{ questionId: "q1", selectedLabel: "SQLite" }],
|
|
57
|
+
outcome: "approved",
|
|
58
|
+
answeredAt: new Date().toISOString(),
|
|
59
|
+
}, "human");
|
|
60
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
61
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { addArtifact, getRun } from "../repository/runs.js";
|
|
3
|
+
import { hasPendingQaExchange, latestExecuteSessionId } from "../repository/result-qa.js";
|
|
4
|
+
import { runQa } from "../runners/qa.js";
|
|
5
|
+
const ASKABLE_STATUSES = new Set(["review", "done"]);
|
|
6
|
+
const QA_RUNTIMES = new Set(["claude_code", "cursor_local"]);
|
|
7
|
+
/** Ask the run's agent about its finished result. Read-only; never changes run status. */
|
|
8
|
+
export function askResultQuestion(runId, question, opts) {
|
|
9
|
+
// An empty question would persist a result_qa artifact that ResultQaContent then
|
|
10
|
+
// refuses to parse — invisible in the thread, and hasPendingQaExchange would miss
|
|
11
|
+
// it, defeating the one-pending-question lock. Reject before spending anything.
|
|
12
|
+
const trimmed = question.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
return { ok: false, code: 400, error: "Question cannot be empty" };
|
|
15
|
+
const run = getRun(runId);
|
|
16
|
+
if (!run)
|
|
17
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
18
|
+
if (!ASKABLE_STATUSES.has(run.status)) {
|
|
19
|
+
return { ok: false, code: 409, error: `Can only ask about a finished result — run is ${run.status}` };
|
|
20
|
+
}
|
|
21
|
+
const runtime = run.runtime ?? "claude_code";
|
|
22
|
+
if (!QA_RUNTIMES.has(runtime)) {
|
|
23
|
+
return { ok: false, code: 409, error: `Q&A is unsupported for runtime ${runtime}` };
|
|
24
|
+
}
|
|
25
|
+
// The pending check and the insert below must stay in one synchronous block: every
|
|
26
|
+
// call here is sync (better-sqlite3), so no other request can interleave between them.
|
|
27
|
+
// Do not introduce an `await` before addArtifact — that would open a double-ask window.
|
|
28
|
+
if (hasPendingQaExchange(runId)) {
|
|
29
|
+
return { ok: false, code: 409, error: "A question is already being answered" };
|
|
30
|
+
}
|
|
31
|
+
const resumeSessionId = latestExecuteSessionId(runId);
|
|
32
|
+
const exchange = {
|
|
33
|
+
exchangeId: randomUUID(),
|
|
34
|
+
question: trimmed,
|
|
35
|
+
status: "pending",
|
|
36
|
+
sessionResumed: Boolean(resumeSessionId),
|
|
37
|
+
askedAt: new Date().toISOString(),
|
|
38
|
+
};
|
|
39
|
+
addArtifact(runId, "result_qa", exchange, "human");
|
|
40
|
+
void answerExchange(run, exchange, resumeSessionId, opts?.runner ?? runQa);
|
|
41
|
+
return { ok: true, exchange };
|
|
42
|
+
}
|
|
43
|
+
async function answerExchange(run, exchange, resumeSessionId, runner) {
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
result = await runner(run, exchange.question, resumeSessionId);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
appendExchange(run.id, { ...exchange, status: "failed", error: String(e) });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
addArtifact(run.id, "usage", result.usage, "agent");
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
appendExchange(run.id, { ...exchange, status: "failed", error: result.error ?? "Q&A failed" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
appendExchange(run.id, {
|
|
58
|
+
...exchange,
|
|
59
|
+
status: "answered",
|
|
60
|
+
answer: result.answer,
|
|
61
|
+
answeredAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function appendExchange(runId, exchange) {
|
|
65
|
+
addArtifact(runId, "result_qa", exchange, "agent");
|
|
66
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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-qa-"));
|
|
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, transitionRun, updateRunFields } = await import("../repository/runs.js");
|
|
12
|
+
const { listQaExchanges, hasPendingQaExchange } = await import("../repository/result-qa.js");
|
|
13
|
+
const { askResultQuestion } = await import("./result-qa.js");
|
|
14
|
+
const USAGE = { phase: "qa", runtime: "claude_code", totalCostUsd: 0.02 };
|
|
15
|
+
before(() => {
|
|
16
|
+
migrate();
|
|
17
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
18
|
+
});
|
|
19
|
+
function seedReviewRun(opts) {
|
|
20
|
+
const run = createRun({
|
|
21
|
+
title: "QA test",
|
|
22
|
+
taskCategory: "other",
|
|
23
|
+
status: "plan_approved",
|
|
24
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
25
|
+
});
|
|
26
|
+
if (opts?.session) {
|
|
27
|
+
addArtifact(run.id, "agent_session", { phase: "execute", runtime: "claude_code", sessionId: opts.session }, "agent");
|
|
28
|
+
}
|
|
29
|
+
transitionRun(run.id, "running");
|
|
30
|
+
transitionRun(run.id, "review");
|
|
31
|
+
return getRun(run.id);
|
|
32
|
+
}
|
|
33
|
+
/** Resolves the async answer job before asserting. */
|
|
34
|
+
async function settle() {
|
|
35
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
36
|
+
}
|
|
37
|
+
test("asking persists a pending exchange, then an answered one with the same exchangeId", async () => {
|
|
38
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
39
|
+
let sawSession = "unset";
|
|
40
|
+
const res = askResultQuestion(run.id, "Why SQLite?", {
|
|
41
|
+
runner: async (_run, _q, resumeSessionId) => {
|
|
42
|
+
sawSession = resumeSessionId;
|
|
43
|
+
return { ok: true, answer: "Single-process.", usage: USAGE };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
assert.equal(res.ok, true);
|
|
47
|
+
assert.equal(res.ok && res.exchange.status, "pending");
|
|
48
|
+
assert.equal(listQaExchanges(run.id)[0].status, "pending");
|
|
49
|
+
await settle();
|
|
50
|
+
assert.equal(sawSession, "sess-exec");
|
|
51
|
+
const thread = listQaExchanges(run.id);
|
|
52
|
+
assert.equal(thread.length, 1, "append-only exchanges collapse by exchangeId");
|
|
53
|
+
assert.equal(thread[0].status, "answered");
|
|
54
|
+
assert.equal(thread[0].answer, "Single-process.");
|
|
55
|
+
assert.equal(thread[0].sessionResumed, true);
|
|
56
|
+
assert.ok(thread[0].answeredAt);
|
|
57
|
+
assert.equal(getLatestArtifact(run.id, "usage").contentJson.includes('"phase":"qa"'), true);
|
|
58
|
+
});
|
|
59
|
+
test("missing execute session answers ungrounded and flags sessionResumed false", async () => {
|
|
60
|
+
const run = seedReviewRun();
|
|
61
|
+
askResultQuestion(run.id, "What did you check?", {
|
|
62
|
+
runner: async () => ({ ok: true, answer: "The tests.", usage: USAGE }),
|
|
63
|
+
});
|
|
64
|
+
await settle();
|
|
65
|
+
const thread = listQaExchanges(run.id);
|
|
66
|
+
assert.equal(thread[0].sessionResumed, false);
|
|
67
|
+
assert.equal(thread[0].status, "answered");
|
|
68
|
+
});
|
|
69
|
+
test("runner failure records a failed exchange and never changes run status", async () => {
|
|
70
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
71
|
+
askResultQuestion(run.id, "Why?", {
|
|
72
|
+
runner: async () => ({ ok: false, answer: "", error: "qa exited 1", usage: USAGE }),
|
|
73
|
+
});
|
|
74
|
+
await settle();
|
|
75
|
+
const thread = listQaExchanges(run.id);
|
|
76
|
+
assert.equal(thread[0].status, "failed");
|
|
77
|
+
assert.equal(thread[0].error, "qa exited 1");
|
|
78
|
+
assert.equal(getRun(run.id).status, "review");
|
|
79
|
+
});
|
|
80
|
+
test("runner throwing records a failed exchange", async () => {
|
|
81
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
82
|
+
askResultQuestion(run.id, "Why?", {
|
|
83
|
+
runner: async () => { throw new Error("spawn ENOENT"); },
|
|
84
|
+
});
|
|
85
|
+
await settle();
|
|
86
|
+
assert.match(listQaExchanges(run.id)[0].error, /spawn ENOENT/);
|
|
87
|
+
});
|
|
88
|
+
test("a second question while one is pending returns 409", () => {
|
|
89
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
90
|
+
askResultQuestion(run.id, "First?", { runner: () => new Promise(() => { }) });
|
|
91
|
+
const second = askResultQuestion(run.id, "Second?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
92
|
+
assert.equal(!second.ok && second.code, 409);
|
|
93
|
+
});
|
|
94
|
+
test("asking a done run is allowed", async () => {
|
|
95
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
96
|
+
transitionRun(run.id, "done");
|
|
97
|
+
const res = askResultQuestion(run.id, "Post-hoc?", {
|
|
98
|
+
runner: async () => ({ ok: true, answer: "Sure.", usage: USAGE }),
|
|
99
|
+
});
|
|
100
|
+
assert.equal(res.ok, true);
|
|
101
|
+
await settle();
|
|
102
|
+
assert.equal(listQaExchanges(run.id)[0].status, "answered");
|
|
103
|
+
});
|
|
104
|
+
test("asking a running run returns 409", () => {
|
|
105
|
+
const run = createRun({
|
|
106
|
+
title: "Still running",
|
|
107
|
+
taskCategory: "other",
|
|
108
|
+
status: "plan_approved",
|
|
109
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
110
|
+
});
|
|
111
|
+
transitionRun(run.id, "running");
|
|
112
|
+
const res = askResultQuestion(run.id, "Why?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
113
|
+
assert.equal(!res.ok && res.code, 409);
|
|
114
|
+
});
|
|
115
|
+
test("whitespace-only question is rejected before spending anything", async () => {
|
|
116
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
117
|
+
let runnerCalled = false;
|
|
118
|
+
const res = askResultQuestion(run.id, " ", {
|
|
119
|
+
runner: async () => { runnerCalled = true; return { ok: true, answer: "x", usage: USAGE }; },
|
|
120
|
+
});
|
|
121
|
+
assert.equal(!res.ok && res.code, 400);
|
|
122
|
+
await settle();
|
|
123
|
+
assert.equal(runnerCalled, false, "no paid agent call");
|
|
124
|
+
assert.equal(listQaExchanges(run.id).length, 0, "no artifact persisted");
|
|
125
|
+
assert.equal(hasPendingQaExchange(run.id), false, "pending lock not corrupted");
|
|
126
|
+
});
|
|
127
|
+
test("a persisted exchange always round-trips through the content schema", async () => {
|
|
128
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
129
|
+
askResultQuestion(run.id, " Why SQLite? ", {
|
|
130
|
+
runner: async () => ({ ok: true, answer: "Single-process.", usage: USAGE }),
|
|
131
|
+
});
|
|
132
|
+
await settle();
|
|
133
|
+
const thread = listQaExchanges(run.id);
|
|
134
|
+
assert.equal(thread.length, 1, "exchange is visible, i.e. it parsed");
|
|
135
|
+
assert.equal(thread[0].question, "Why SQLite?", "stored trimmed");
|
|
136
|
+
});
|
|
137
|
+
test("unknown run returns 404", () => {
|
|
138
|
+
const res = askResultQuestion("00000000-0000-4000-a000-0000000000ff", "Why?");
|
|
139
|
+
assert.equal(!res.ok && res.code, 404);
|
|
140
|
+
});
|
|
141
|
+
test("cursor runtime is allowed", () => {
|
|
142
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
143
|
+
updateRunFields(run.id, { runtime: "cursor_local" });
|
|
144
|
+
const res = askResultQuestion(run.id, "Why?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
145
|
+
assert.equal(res.ok, true);
|
|
146
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ResultQaContent as ResultQaContentSchema, latestQaExchanges } from "@agent-dealer/shared";
|
|
2
|
+
import { listArtifacts } from "./runs.js";
|
|
3
|
+
/** Collapsed Q&A thread for a run — latest artifact per exchangeId, oldest ask first. */
|
|
4
|
+
export function listQaExchanges(runId) {
|
|
5
|
+
const parsed = [];
|
|
6
|
+
for (const art of listArtifacts(runId)) {
|
|
7
|
+
if (art.kind !== "result_qa" || !art.contentJson)
|
|
8
|
+
continue;
|
|
9
|
+
try {
|
|
10
|
+
parsed.push(ResultQaContentSchema.parse(JSON.parse(art.contentJson)));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// skip malformed
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return latestQaExchanges(parsed);
|
|
17
|
+
}
|
|
18
|
+
export function hasPendingQaExchange(runId) {
|
|
19
|
+
return listQaExchanges(runId).some((e) => e.status === "pending");
|
|
20
|
+
}
|
|
21
|
+
/** Session id of the most recent execute phase — what a Q&A resumes into. */
|
|
22
|
+
export function latestExecuteSessionId(runId) {
|
|
23
|
+
const sessions = listArtifacts(runId).filter((a) => a.kind === "agent_session");
|
|
24
|
+
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
25
|
+
if (!sessions[i].contentJson)
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(sessions[i].contentJson);
|
|
29
|
+
if (parsed.phase === "execute" && parsed.sessionId)
|
|
30
|
+
return parsed.sessionId;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
|
|
2
|
+
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, ResultQaInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
|
|
3
3
|
import { parseRunBudget } from "@agent-dealer/shared";
|
|
4
4
|
import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
|
|
5
5
|
import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
|
|
6
6
|
import { listAgentsWithHealth } from "../adapters/agent-health.js";
|
|
7
7
|
import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
|
|
8
8
|
import { approveRunWithDeliver } from "../queue/approve-deliver.js";
|
|
9
|
+
import { recordPlanDelegation } from "../queue/plan-delegation.js";
|
|
10
|
+
import { askResultQuestion } from "../queue/result-qa.js";
|
|
9
11
|
import { rejectPendingOutboundDrafts } from "../repository/outbound-drafts.js";
|
|
10
12
|
import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
|
|
11
13
|
import { testAgentDeckConnection } from "../adapters/agent-deck.js";
|
|
@@ -302,6 +304,7 @@ export async function registerRoutes(app) {
|
|
|
302
304
|
if (input.executeBudget !== undefined) {
|
|
303
305
|
patchRunPhaseBudget(id, "execute", input.executeBudget);
|
|
304
306
|
}
|
|
307
|
+
recordPlanDelegation(id);
|
|
305
308
|
markPlanTriageConsumed(id);
|
|
306
309
|
try {
|
|
307
310
|
transitionRun(id, "plan_approved");
|
|
@@ -336,6 +339,20 @@ export async function registerRoutes(app) {
|
|
|
336
339
|
return reply.status(result.code).send({ error: result.error });
|
|
337
340
|
return { run: result.run, outcome: result.outcome };
|
|
338
341
|
});
|
|
342
|
+
app.post("/api/runs/:id/qa", async (req, reply) => {
|
|
343
|
+
const { id } = req.params;
|
|
344
|
+
let input;
|
|
345
|
+
try {
|
|
346
|
+
input = ResultQaInput.parse(req.body);
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
return reply.status(400).send({ error: String(e) });
|
|
350
|
+
}
|
|
351
|
+
const result = askResultQuestion(id, input.question);
|
|
352
|
+
if (!result.ok)
|
|
353
|
+
return reply.status(result.code).send({ error: result.error });
|
|
354
|
+
return reply.status(202).send({ exchange: result.exchange });
|
|
355
|
+
});
|
|
339
356
|
app.post("/api/runs/:id/kick", async (req, reply) => {
|
|
340
357
|
const { id } = req.params;
|
|
341
358
|
const run = getRun(id);
|
|
@@ -2,6 +2,8 @@ import { getTemporalOutputDir } from "../paths.js";
|
|
|
2
2
|
export const DENY_SEND_TOOL = "mcp__agent-deck__call_service_tool";
|
|
3
3
|
const DECK_READ_TOOLS = "mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace,mcp__agent-deck__list_service_tools";
|
|
4
4
|
const PLAN_REFLECT_TOOLS = `Read,Glob,Grep,Skill,${DECK_READ_TOOLS}`;
|
|
5
|
+
/** Q&A about a finished result — read-only, no deck writes, no workspace mutation. */
|
|
6
|
+
const QA_TOOLS = "Read,Glob,Grep,Skill";
|
|
5
7
|
function executeToolsForCategory(category) {
|
|
6
8
|
const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill", DECK_READ_TOOLS];
|
|
7
9
|
if (category !== "communication" && category !== "email") {
|
|
@@ -12,7 +14,10 @@ function executeToolsForCategory(category) {
|
|
|
12
14
|
/** Build claude -p CLI args for a phase (excluding prompt, model, resume, mcp-config, budget). */
|
|
13
15
|
export function buildClaudePhaseArgs(run, mode) {
|
|
14
16
|
const args = ["--output-format", "stream-json", "--verbose"];
|
|
15
|
-
if (mode === "
|
|
17
|
+
if (mode === "qa") {
|
|
18
|
+
args.push("--allowedTools", QA_TOOLS);
|
|
19
|
+
}
|
|
20
|
+
else if (mode === "plan" || mode === "reflect") {
|
|
16
21
|
args.push("--allowedTools", PLAN_REFLECT_TOOLS);
|
|
17
22
|
}
|
|
18
23
|
else {
|
|
@@ -6,7 +6,7 @@ import { humanFeedbackText, lineageParentExecuteSessionId } from "./run-context.
|
|
|
6
6
|
import { getTemporalLogsDir } from "../paths.js";
|
|
7
7
|
import { resolveClaudeBin, resolveCursorBin } from "../cli-env.js";
|
|
8
8
|
import { resolveBudgetForPhase, resolveModelForPhase, getRun } from "../repository/runs.js";
|
|
9
|
-
import { budgetCliArgs } from "@agent-dealer/shared";
|
|
9
|
+
import { budgetCliArgs, QA_PHASE_BUDGET } from "@agent-dealer/shared";
|
|
10
10
|
import { buildClaudePhaseArgs } from "./claude-args.js";
|
|
11
11
|
async function spawnCli(cmd, args, cwd) {
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
@@ -31,7 +31,10 @@ export async function runClaude(run, mode = "execute", model, opts) {
|
|
|
31
31
|
if (!fs.existsSync(mcpConfig)) {
|
|
32
32
|
throw new Error(`MCP config not found: ${mcpConfig}. Set CLAUDE_MCP_CONFIG.`);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
if (mode === "qa" && !opts?.promptOverride) {
|
|
35
|
+
throw new Error("qa mode requires a promptOverride");
|
|
36
|
+
}
|
|
37
|
+
const phaseBudget = mode === "qa" ? { ...QA_PHASE_BUDGET } : resolveBudgetForPhase(run, mode);
|
|
35
38
|
const resumeSessionId = opts?.resumeSessionId ??
|
|
36
39
|
(mode === "execute" && humanFeedbackText(run) ? lineageParentExecuteSessionId(run) : null);
|
|
37
40
|
const prompt = opts?.promptOverride ??
|
|
@@ -42,7 +45,7 @@ export async function runClaude(run, mode = "execute", model, opts) {
|
|
|
42
45
|
: resumeSessionId
|
|
43
46
|
? buildExecutionContinuationPrompt(run)
|
|
44
47
|
: buildExecutionPrompt(run));
|
|
45
|
-
const logPath = logPathFor(run, mode
|
|
48
|
+
const logPath = logPathFor(run, mode);
|
|
46
49
|
const args = [
|
|
47
50
|
...(model ? ["--model", model] : []),
|
|
48
51
|
...(resumeSessionId ? ["--resume", resumeSessionId] : []),
|
|
@@ -58,8 +61,11 @@ export async function runClaude(run, mode = "execute", model, opts) {
|
|
|
58
61
|
fs.writeFileSync(logPath, transcript);
|
|
59
62
|
return { exitCode, transcript, logPath };
|
|
60
63
|
}
|
|
61
|
-
export async function runCursor(run, mode = "execute", model) {
|
|
62
|
-
|
|
64
|
+
export async function runCursor(run, mode = "execute", model, opts) {
|
|
65
|
+
if (mode === "qa" && !opts?.promptOverride) {
|
|
66
|
+
throw new Error("qa mode requires a promptOverride");
|
|
67
|
+
}
|
|
68
|
+
const prompt = opts?.promptOverride ?? (mode === "plan" ? buildPlanPrompt(run) : buildExecutionPrompt(run));
|
|
63
69
|
const logPath = logPathFor(run, mode);
|
|
64
70
|
const args = [
|
|
65
71
|
"agent",
|
|
@@ -68,6 +74,8 @@ export async function runCursor(run, mode = "execute", model) {
|
|
|
68
74
|
"--output-format",
|
|
69
75
|
"stream-json",
|
|
70
76
|
"--stream-partial-output",
|
|
77
|
+
...(mode === "qa" ? ["--mode", "ask"] : []),
|
|
78
|
+
...(opts?.resumeSessionId ? ["--resume", opts.resumeSessionId] : []),
|
|
71
79
|
...(model ? ["--model", model] : []),
|
|
72
80
|
prompt,
|
|
73
81
|
];
|
|
@@ -34,11 +34,28 @@ function hasDisallowSend(args) {
|
|
|
34
34
|
return i >= 0 && args[i + 1] === DENY_SEND_TOOL;
|
|
35
35
|
}
|
|
36
36
|
test("all phases deny call_service_tool", () => {
|
|
37
|
-
for (const mode of ["plan", "execute", "reflect"]) {
|
|
37
|
+
for (const mode of ["plan", "execute", "reflect", "qa"]) {
|
|
38
38
|
const args = buildClaudePhaseArgs(runWithCategory("code"), mode);
|
|
39
39
|
assert.equal(hasDisallowSend(args), true, mode);
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
|
+
test("qa allowlist is read-only", () => {
|
|
43
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "qa");
|
|
44
|
+
const i = args.indexOf("--allowedTools");
|
|
45
|
+
assert.equal(args[i + 1], "Read,Glob,Grep,Skill");
|
|
46
|
+
});
|
|
47
|
+
test("qa forbids mutation tools even for code tasks", () => {
|
|
48
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "qa");
|
|
49
|
+
const i = args.indexOf("--allowedTools");
|
|
50
|
+
for (const tool of ["Write", "Edit", "Bash"]) {
|
|
51
|
+
assert.doesNotMatch(args[i + 1], new RegExp(`\\b${tool}\\b`), tool);
|
|
52
|
+
}
|
|
53
|
+
assert.equal(hasDisallowSend(args), true);
|
|
54
|
+
});
|
|
55
|
+
test("qa does not add the deliverable output dir", () => {
|
|
56
|
+
const args = buildClaudePhaseArgs(runWithCategory("content"), "qa");
|
|
57
|
+
assert.equal(args.includes("--add-dir"), false);
|
|
58
|
+
});
|
|
42
59
|
test("execute allowlist includes list_service_tools", () => {
|
|
43
60
|
const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
|
|
44
61
|
const i = args.indexOf("--allowedTools");
|
|
@@ -53,7 +53,7 @@ export function persistRunOutput(input) {
|
|
|
53
53
|
const blocker = detectExecutionBlocker(resultText);
|
|
54
54
|
const isError = exitCode !== 0 || streamError || blocker.detected;
|
|
55
55
|
const usage = extractUsage(events, phase, runtime);
|
|
56
|
-
const caps = resolveBudgetForPhase(run, phase);
|
|
56
|
+
const caps = phase === "qa" ? null : resolveBudgetForPhase(run, phase);
|
|
57
57
|
if (caps?.maxTurns != null)
|
|
58
58
|
usage.maxTurns = caps.maxTurns;
|
|
59
59
|
if (caps?.maxBudgetUsd != null)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getLatestArtifact } from "../repository/runs.js";
|
|
2
2
|
import { documentOutputHint } from "./persist.js";
|
|
3
|
-
import { humanFeedbackText, appendPlanRetrySections, appendExecutionRetrySections, retryContext, } from "./run-context.js";
|
|
3
|
+
import { humanFeedbackText, appendPlanRetrySections, appendExecutionRetrySections, retryContext, reviewQaPairs, } from "./run-context.js";
|
|
4
4
|
export function workspaceForRun(run) {
|
|
5
5
|
return run.repo ?? run.artifactWorkspace ?? process.cwd();
|
|
6
6
|
}
|
|
@@ -45,6 +45,13 @@ function outboundDraftContractSection() {
|
|
|
45
45
|
"- Do not perform the send — the human approves and the server delivers verbatim.",
|
|
46
46
|
].join("\n");
|
|
47
47
|
}
|
|
48
|
+
function reviewQaSections(run) {
|
|
49
|
+
const pairs = reviewQaPairs(run);
|
|
50
|
+
if (pairs.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
const lines = pairs.flatMap((p) => [`Q: ${p.question}`, `A: ${p.answer}`, ``]);
|
|
53
|
+
return [`## Review Q&A`, `The human asked about the prior result. Honor these answers.`, ...lines];
|
|
54
|
+
}
|
|
48
55
|
export function buildExecutionContinuationPrompt(run) {
|
|
49
56
|
const ctx = retryContext(run);
|
|
50
57
|
const parts = [
|
|
@@ -64,6 +71,7 @@ export function buildExecutionContinuationPrompt(run) {
|
|
|
64
71
|
parts.push(`## Human feedback`, feedback, ``);
|
|
65
72
|
}
|
|
66
73
|
}
|
|
74
|
+
parts.push(...reviewQaSections(run));
|
|
67
75
|
if (run.deckId) {
|
|
68
76
|
parts.push(`Use Agent Deck: bind_workspace({ deckId: "${run.deckId}", workspaceRoot: "${workspaceForRun(run)}" })`);
|
|
69
77
|
if (run.playbookId) {
|
|
@@ -104,6 +112,8 @@ export function buildExecutionPrompt(run) {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
parts.push(...planAnswersSections(run));
|
|
115
|
+
parts.push(...planDelegationSections(run));
|
|
116
|
+
parts.push(...reviewQaSections(run));
|
|
107
117
|
if (run.taskCategory === "content" || run.taskCategory === "research") {
|
|
108
118
|
parts.push(`## Deliverable`, documentOutputHint(run), `You MUST write the file using your Write/edit tool — do not only print markdown in chat.`, ``);
|
|
109
119
|
}
|
|
@@ -155,6 +165,35 @@ export function buildReflectPrompt(run, opts) {
|
|
|
155
165
|
}
|
|
156
166
|
return parts.join("\n");
|
|
157
167
|
}
|
|
168
|
+
export function buildQaPrompt(run, question, opts) {
|
|
169
|
+
const parts = [
|
|
170
|
+
`The human reviewing your finished work has a question about it.`,
|
|
171
|
+
``,
|
|
172
|
+
`IMPORTANT:`,
|
|
173
|
+
`- Answer the question. Do NOT modify anything — no files, no edits, no commands, no sends.`,
|
|
174
|
+
`- You may read files to check your own work.`,
|
|
175
|
+
`- Answer concisely in markdown. No preamble, no restating the question.`,
|
|
176
|
+
`- If you do not know, say so plainly rather than guessing.`,
|
|
177
|
+
``,
|
|
178
|
+
];
|
|
179
|
+
if (opts.grounded) {
|
|
180
|
+
parts.push(`## Task`, taskText(run), ``);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
parts.push(`Your original session is gone, so the relevant context is reproduced below.`, ``, `## Task`, taskText(run), ``);
|
|
184
|
+
const plan = artifactMarkdown("approved_plan", run.id);
|
|
185
|
+
if (plan)
|
|
186
|
+
parts.push(`## Approved plan`, plan, ``);
|
|
187
|
+
const result = artifactMarkdown("execution_result", run.id);
|
|
188
|
+
if (result)
|
|
189
|
+
parts.push(`## Execution outcome`, result, ``);
|
|
190
|
+
const doc = artifactMarkdown("document", run.id);
|
|
191
|
+
if (doc)
|
|
192
|
+
parts.push(`## Deliverable`, doc, ``);
|
|
193
|
+
}
|
|
194
|
+
parts.push(`## Question`, question);
|
|
195
|
+
return parts.join("\n");
|
|
196
|
+
}
|
|
158
197
|
function hasOutboundSendGate(category) {
|
|
159
198
|
return category === "communication" || category === "email";
|
|
160
199
|
}
|
|
@@ -200,6 +239,35 @@ function planAnswersSections(run) {
|
|
|
200
239
|
return [];
|
|
201
240
|
}
|
|
202
241
|
}
|
|
242
|
+
function planDelegationSections(run) {
|
|
243
|
+
const ansArt = getLatestArtifact(run.id, "plan_answers");
|
|
244
|
+
if (!ansArt?.contentJson)
|
|
245
|
+
return [];
|
|
246
|
+
try {
|
|
247
|
+
const ans = JSON.parse(ansArt.contentJson);
|
|
248
|
+
if (ans.outcome !== "delegated")
|
|
249
|
+
return [];
|
|
250
|
+
const triArt = getLatestArtifact(run.id, "plan_triage");
|
|
251
|
+
const questions = triArt?.contentJson
|
|
252
|
+
? JSON.parse(triArt.contentJson).questions
|
|
253
|
+
: [];
|
|
254
|
+
if (questions.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
const lines = questions.map((q) => {
|
|
257
|
+
const options = q.options.map((o) => o.label).join(" | ");
|
|
258
|
+
return `- ${q.question} (options: ${options})`;
|
|
259
|
+
});
|
|
260
|
+
return [
|
|
261
|
+
"## Unanswered plan questions",
|
|
262
|
+
"The reviewer chose to proceed without answering these. Use your best judgment.",
|
|
263
|
+
...lines,
|
|
264
|
+
"",
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
203
271
|
export function buildPlanRevisePrompt(run, questions, answers) {
|
|
204
272
|
const qa = answers.map((a) => {
|
|
205
273
|
const q = questions.find((x) => x.id === a.questionId);
|