agent-dealer 0.1.2 → 0.1.4
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/adapters/agent-deck-deliver.test.js +52 -0
- package/bundle/server/dist/adapters/agent-deck.js +140 -0
- package/bundle/server/dist/queue/answers.test.js +117 -0
- package/bundle/server/dist/queue/approve-deliver.js +62 -0
- package/bundle/server/dist/queue/dispatcher.js +176 -2
- package/bundle/server/dist/queue/plan-gate.test.js +55 -0
- package/bundle/server/dist/queue/send-gate.test.js +108 -0
- package/bundle/server/dist/repository/outbound-drafts.js +62 -0
- package/bundle/server/dist/repository/runs.js +17 -1
- package/bundle/server/dist/routes/index.js +41 -17
- package/bundle/server/dist/runners/claude-args.js +24 -0
- package/bundle/server/dist/runners/claude.js +9 -18
- package/bundle/server/dist/runners/claude.test.js +58 -0
- package/bundle/server/dist/runners/persist.js +46 -7
- package/bundle/server/dist/runners/prompts.js +87 -1
- package/bundle/server/dist/runners/prompts.test.js +92 -0
- package/bundle/server/dist/runners/reflect.js +1 -1
- package/bundle/server/dist/runners/stream-json.js +51 -0
- package/bundle/server/dist/runners/stream-json.test.js +95 -0
- package/bundle/server/dist/usage-summary.js +9 -4
- package/bundle/server/package.json +3 -2
- package/bundle/server/static-ui/assets/index-Cc-h06U6.js +78 -0
- package/bundle/server/static-ui/assets/index-DKnME33I.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/budget.d.ts +1 -1
- package/bundle/shared/dist/budget.js +3 -3
- package/bundle/shared/dist/index.d.ts +351 -129
- package/bundle/shared/dist/index.js +19 -0
- package/bundle/shared/dist/outbound-draft.d.ts +192 -0
- package/bundle/shared/dist/outbound-draft.js +46 -0
- package/bundle/shared/dist/outbound-draft.test.d.ts +1 -0
- package/bundle/shared/dist/outbound-draft.test.js +34 -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 +3 -2
- package/bundle/server/static-ui/assets/index-ChdPKYDd.css +0 -1
- package/bundle/server/static-ui/assets/index-hIzZ8TM8.js +0 -77
|
@@ -0,0 +1,108 @@
|
|
|
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-send-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, transitionRun, updateRunFields, } = await import("../repository/runs.js");
|
|
12
|
+
const { approveRunWithDeliver } = await import("./approve-deliver.js");
|
|
13
|
+
const { rejectPendingOutboundDrafts, pendingSendCount } = await import("../repository/outbound-drafts.js");
|
|
14
|
+
const { getSnapshot } = await import("./dispatcher.js");
|
|
15
|
+
const TOOL_CALL = {
|
|
16
|
+
serviceName: "34eb6c24-f151-4da2-8db8-d6996aa296be",
|
|
17
|
+
toolName: "chat_postMessage",
|
|
18
|
+
arguments: { channel: "C1", text: "Hello gate test" },
|
|
19
|
+
};
|
|
20
|
+
const DRAFT_CONTENT = {
|
|
21
|
+
draft: {
|
|
22
|
+
actionType: "slack_message",
|
|
23
|
+
summary: { target: "#test", body: "Hello gate test" },
|
|
24
|
+
toolCall: TOOL_CALL,
|
|
25
|
+
},
|
|
26
|
+
status: "pending",
|
|
27
|
+
};
|
|
28
|
+
before(() => {
|
|
29
|
+
migrate();
|
|
30
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
31
|
+
});
|
|
32
|
+
function seedReviewRun(withDraft = true) {
|
|
33
|
+
const run = createRun({
|
|
34
|
+
title: "Send gate test",
|
|
35
|
+
taskCategory: "communication",
|
|
36
|
+
status: "plan_pending",
|
|
37
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
38
|
+
});
|
|
39
|
+
updateRunFields(run.id, { deck_id: "6e825b59-13de-4ddd-ab7e-55ab5a1c279a" });
|
|
40
|
+
transitionRun(run.id, "plan_approved");
|
|
41
|
+
transitionRun(run.id, "running");
|
|
42
|
+
transitionRun(run.id, "review");
|
|
43
|
+
if (withDraft) {
|
|
44
|
+
addArtifact(run.id, "slack_draft", DRAFT_CONTENT, "agent");
|
|
45
|
+
}
|
|
46
|
+
return getRun(run.id);
|
|
47
|
+
}
|
|
48
|
+
test("pendingSendCount is 1 when draft pending", () => {
|
|
49
|
+
const run = seedReviewRun(true);
|
|
50
|
+
assert.equal(pendingSendCount(run.id), 1);
|
|
51
|
+
});
|
|
52
|
+
test("approve without draft transitions to done", async () => {
|
|
53
|
+
const run = seedReviewRun(false);
|
|
54
|
+
const res = await approveRunWithDeliver(run.id);
|
|
55
|
+
assert.equal(res.ok, true);
|
|
56
|
+
assert.equal(res.ok && res.delivered, false);
|
|
57
|
+
assert.equal(getRun(run.id).status, "done");
|
|
58
|
+
});
|
|
59
|
+
test("approve with mock deliver sends byte-identical payload and creates receipt", async () => {
|
|
60
|
+
const run = seedReviewRun(true);
|
|
61
|
+
let captured = null;
|
|
62
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
63
|
+
deliver: async (_deckId, toolCall) => {
|
|
64
|
+
captured = toolCall;
|
|
65
|
+
return { toolResult: { ok: true }, permalink: "https://slack.example/msg/1" };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
assert.equal(res.ok, true);
|
|
69
|
+
assert.equal(res.ok && res.delivered, true);
|
|
70
|
+
assert.deepEqual(captured, TOOL_CALL);
|
|
71
|
+
assert.equal(getRun(run.id).status, "done");
|
|
72
|
+
assert.ok(getLatestArtifact(run.id, "send_receipt"));
|
|
73
|
+
const draftArt = getLatestArtifact(run.id, "slack_draft");
|
|
74
|
+
assert.match(draftArt.contentJson, /"status":"sent"/);
|
|
75
|
+
});
|
|
76
|
+
test("deliver failure keeps run in review with pending draft", async () => {
|
|
77
|
+
const run = seedReviewRun(true);
|
|
78
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
79
|
+
deliver: async () => {
|
|
80
|
+
throw new Error("deck down");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
assert.equal(res.ok, false);
|
|
84
|
+
assert.equal(!res.ok && res.code, 502);
|
|
85
|
+
assert.equal(getRun(run.id).status, "review");
|
|
86
|
+
assert.equal(pendingSendCount(run.id), 1);
|
|
87
|
+
});
|
|
88
|
+
test("proxy success:false from deck is treated as deliver failure", async () => {
|
|
89
|
+
const run = seedReviewRun(true);
|
|
90
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
91
|
+
deliver: async () => {
|
|
92
|
+
throw new Error("Service not found");
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
assert.equal(res.ok, false);
|
|
96
|
+
assert.equal(getRun(run.id).status, "review");
|
|
97
|
+
});
|
|
98
|
+
test("retry rejects pending draft", () => {
|
|
99
|
+
const run = seedReviewRun(true);
|
|
100
|
+
rejectPendingOutboundDrafts(run.id);
|
|
101
|
+
assert.equal(pendingSendCount(run.id), 0);
|
|
102
|
+
const draftArt = getLatestArtifact(run.id, "slack_draft");
|
|
103
|
+
assert.match(draftArt.contentJson, /"status":"rejected"/);
|
|
104
|
+
});
|
|
105
|
+
test("snapshot exposes pendingSendCounts", () => {
|
|
106
|
+
const snap = getSnapshot();
|
|
107
|
+
assert.equal(typeof snap.pendingSendCounts, "object");
|
|
108
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isOutboundDraftKind } from "@agent-dealer/shared";
|
|
2
|
+
import { getDb } from "../db/index.js";
|
|
3
|
+
import { listArtifacts } from "./runs.js";
|
|
4
|
+
export function getPendingOutboundDraft(runId) {
|
|
5
|
+
const arts = listArtifacts(runId).filter((a) => isOutboundDraftKind(a.kind) && a.contentJson);
|
|
6
|
+
for (let i = arts.length - 1; i >= 0; i--) {
|
|
7
|
+
const art = arts[i];
|
|
8
|
+
try {
|
|
9
|
+
const content = JSON.parse(art.contentJson);
|
|
10
|
+
if (content.status === "pending") {
|
|
11
|
+
return { artifact: art, content };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// skip
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function pendingSendCount(runId) {
|
|
21
|
+
return getPendingOutboundDraft(runId) ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
export function rejectPendingOutboundDrafts(runId) {
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
const db = getDb();
|
|
26
|
+
for (const art of listArtifacts(runId)) {
|
|
27
|
+
if (!isOutboundDraftKind(art.kind) || !art.contentJson)
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const content = JSON.parse(art.contentJson);
|
|
31
|
+
if (content.status !== "pending")
|
|
32
|
+
continue;
|
|
33
|
+
const next = { ...content, status: "rejected", rejectedAt: now };
|
|
34
|
+
db.prepare("UPDATE artifacts SET content_json = ? WHERE id = ?").run(JSON.stringify(next), art.id);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// skip
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Atomic pending → sent transition. Returns false if not pending. */
|
|
42
|
+
export function markOutboundDraftSent(artifactId) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const row = db.prepare("SELECT content_json FROM artifacts WHERE id = ?").get(artifactId);
|
|
45
|
+
if (!row?.content_json)
|
|
46
|
+
return false;
|
|
47
|
+
let content;
|
|
48
|
+
try {
|
|
49
|
+
content = JSON.parse(row.content_json);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (content.status !== "pending")
|
|
55
|
+
return false;
|
|
56
|
+
const next = { ...content, status: "sent", sentAt: new Date().toISOString() };
|
|
57
|
+
const result = db
|
|
58
|
+
.prepare("UPDATE artifacts SET content_json = ? WHERE id = ? AND content_json = ?")
|
|
59
|
+
.run(JSON.stringify(next), artifactId, row.content_json);
|
|
60
|
+
return result.changes === 1;
|
|
61
|
+
}
|
|
62
|
+
export const deliverInFlight = new Set();
|
|
@@ -168,7 +168,8 @@ export function updateRunBudget(id, patch) {
|
|
|
168
168
|
export function patchRunPhaseBudget(id, phase, budget) {
|
|
169
169
|
if (budget === undefined)
|
|
170
170
|
return getRun(id);
|
|
171
|
-
|
|
171
|
+
// null = clear run-level override for this phase (revert to agent defaults)
|
|
172
|
+
return updateRunBudget(id, { [phase]: budget });
|
|
172
173
|
}
|
|
173
174
|
export function resolveModelForPhase(run, phase) {
|
|
174
175
|
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
@@ -387,6 +388,21 @@ export function getLatestArtifact(runId, kind) {
|
|
|
387
388
|
createdAt: row.created_at,
|
|
388
389
|
};
|
|
389
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
|
+
}
|
|
390
406
|
const OPS_BOARD_STATUSES = [
|
|
391
407
|
"queued",
|
|
392
408
|
"plan_pending",
|
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
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
3
|
import { parseRunBudget } from "@agent-dealer/shared";
|
|
4
|
-
import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
|
|
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
|
-
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";
|
|
8
|
+
import { approveRunWithDeliver } from "../queue/approve-deliver.js";
|
|
9
|
+
import { rejectPendingOutboundDrafts } from "../repository/outbound-drafts.js";
|
|
8
10
|
import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
|
|
9
11
|
import { testAgentDeckConnection } from "../adapters/agent-deck.js";
|
|
10
12
|
import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
|
|
@@ -53,11 +55,12 @@ export async function registerRoutes(app) {
|
|
|
53
55
|
const run = getRun(id);
|
|
54
56
|
if (!run)
|
|
55
57
|
return reply.status(404).send({ error: "Not found" });
|
|
58
|
+
const artifacts = listArtifacts(id);
|
|
56
59
|
return {
|
|
57
60
|
run,
|
|
58
|
-
artifacts
|
|
61
|
+
artifacts,
|
|
59
62
|
events: listEvents(id),
|
|
60
|
-
usageSummary: buildLineageUsageSummary(run),
|
|
63
|
+
usageSummary: buildLineageUsageSummary(run, { artifactsByRunId: { [id]: artifacts } }),
|
|
61
64
|
traceSummary: buildLineageTraceSummary(run),
|
|
62
65
|
};
|
|
63
66
|
});
|
|
@@ -272,8 +275,6 @@ export async function registerRoutes(app) {
|
|
|
272
275
|
if (!run)
|
|
273
276
|
return reply.status(404).send({ error: "Not found" });
|
|
274
277
|
const input = UpdatePlanInput.parse(req.body);
|
|
275
|
-
const kind = input.approve ? "approved_plan" : "draft_plan";
|
|
276
|
-
addArtifact(id, kind, { markdown: input.planMarkdown }, "human");
|
|
277
278
|
if (input.approve) {
|
|
278
279
|
const hasPlan = listArtifacts(id).some((a) => a.kind === "draft_plan") ||
|
|
279
280
|
input.planMarkdown.trim().length > 0;
|
|
@@ -301,16 +302,40 @@ export async function registerRoutes(app) {
|
|
|
301
302
|
if (input.executeBudget !== undefined) {
|
|
302
303
|
patchRunPhaseBudget(id, "execute", input.executeBudget);
|
|
303
304
|
}
|
|
304
|
-
|
|
305
|
+
markPlanTriageConsumed(id);
|
|
306
|
+
try {
|
|
307
|
+
transitionRun(id, "plan_approved");
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
return reply.status(409).send({ error: String(e) });
|
|
311
|
+
}
|
|
312
|
+
addArtifact(id, "approved_plan", { markdown: input.planMarkdown }, "human");
|
|
313
|
+
const updated = getRun(id);
|
|
305
314
|
syncLinearForRun(updated, "plan_approved").catch((e) => console.error("[linear-sync] plan_approved:", e));
|
|
306
315
|
await forceDispatch();
|
|
307
316
|
return updated;
|
|
308
317
|
}
|
|
318
|
+
addArtifact(id, "draft_plan", { markdown: input.planMarkdown }, "human");
|
|
319
|
+
markPlanTriageConsumed(id);
|
|
309
320
|
if (run.status === "queued") {
|
|
310
321
|
return transitionRun(id, "plan_pending");
|
|
311
322
|
}
|
|
312
323
|
return getRun(id);
|
|
313
324
|
});
|
|
325
|
+
app.post("/api/runs/:id/plan/answers", async (req, reply) => {
|
|
326
|
+
const { id } = req.params;
|
|
327
|
+
let input;
|
|
328
|
+
try {
|
|
329
|
+
input = PlanAnswersInput.parse(req.body);
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
return reply.status(400).send({ error: String(e) });
|
|
333
|
+
}
|
|
334
|
+
const result = submitPlanAnswers(id, input);
|
|
335
|
+
if (!result.ok)
|
|
336
|
+
return reply.status(result.code).send({ error: result.error });
|
|
337
|
+
return { run: result.run, outcome: result.outcome };
|
|
338
|
+
});
|
|
314
339
|
app.post("/api/runs/:id/kick", async (req, reply) => {
|
|
315
340
|
const { id } = req.params;
|
|
316
341
|
const run = getRun(id);
|
|
@@ -350,16 +375,11 @@ export async function registerRoutes(app) {
|
|
|
350
375
|
});
|
|
351
376
|
app.post("/api/runs/:id/approve", async (req, reply) => {
|
|
352
377
|
const { id } = req.params;
|
|
353
|
-
const
|
|
354
|
-
if (!
|
|
355
|
-
return reply.status(
|
|
356
|
-
if (run.status !== "review") {
|
|
357
|
-
return reply.status(400).send({ error: "Run must be in review" });
|
|
378
|
+
const result = await approveRunWithDeliver(id);
|
|
379
|
+
if (!result.ok) {
|
|
380
|
+
return reply.status(result.code).send({ error: result.error });
|
|
358
381
|
}
|
|
359
|
-
|
|
360
|
-
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
361
|
-
scheduleReflect(updated, { trigger: "approve" });
|
|
362
|
-
return updated;
|
|
382
|
+
return result.run;
|
|
363
383
|
});
|
|
364
384
|
app.post("/api/runs/:id/retry", async (req, reply) => {
|
|
365
385
|
const { id } = req.params;
|
|
@@ -375,6 +395,7 @@ export async function registerRoutes(app) {
|
|
|
375
395
|
}
|
|
376
396
|
const input = RetryRunInput.parse(req.body);
|
|
377
397
|
addArtifact(id, "feedback", { markdown: input.feedback }, "human");
|
|
398
|
+
rejectPendingOutboundDrafts(id);
|
|
378
399
|
const retry = createRun({
|
|
379
400
|
title: run.title,
|
|
380
401
|
description: run.description ?? undefined,
|
|
@@ -394,6 +415,9 @@ export async function registerRoutes(app) {
|
|
|
394
415
|
runtime: run.runtime,
|
|
395
416
|
execute_model: input.executeModel ?? input.planModel ?? run.executeModel,
|
|
396
417
|
});
|
|
418
|
+
if (input.executeBudget !== undefined) {
|
|
419
|
+
patchRunPhaseBudget(retry.id, "execute", input.executeBudget);
|
|
420
|
+
}
|
|
397
421
|
try {
|
|
398
422
|
addArtifact(retry.id, "approved_plan", JSON.parse(approvedPlan.contentJson), approvedPlan.author);
|
|
399
423
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getTemporalOutputDir } from "../paths.js";
|
|
2
|
+
export const DENY_SEND_TOOL = "mcp__agent-deck__call_service_tool";
|
|
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
|
+
const PLAN_REFLECT_TOOLS = `Read,Glob,Grep,Skill,${DECK_READ_TOOLS}`;
|
|
5
|
+
function executeToolsForCategory(category) {
|
|
6
|
+
const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill", DECK_READ_TOOLS];
|
|
7
|
+
if (category !== "communication" && category !== "email") {
|
|
8
|
+
base.splice(5, 0, "Bash");
|
|
9
|
+
}
|
|
10
|
+
return base.join(",");
|
|
11
|
+
}
|
|
12
|
+
/** Build claude -p CLI args for a phase (excluding prompt, model, resume, mcp-config, budget). */
|
|
13
|
+
export function buildClaudePhaseArgs(run, mode) {
|
|
14
|
+
const args = ["--output-format", "stream-json", "--verbose"];
|
|
15
|
+
if (mode === "plan" || mode === "reflect") {
|
|
16
|
+
args.push("--allowedTools", PLAN_REFLECT_TOOLS);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
args.push("--add-dir", getTemporalOutputDir());
|
|
20
|
+
args.push("--allowedTools", executeToolsForCategory(run.taskCategory));
|
|
21
|
+
}
|
|
22
|
+
args.push("--disallowedTools", DENY_SEND_TOOL);
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
@@ -3,10 +3,11 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildExecutionPrompt, buildExecutionContinuationPrompt, buildPlanPrompt, buildReflectPrompt, workspaceForRun } from "./prompts.js";
|
|
5
5
|
import { humanFeedbackText, lineageParentExecuteSessionId } from "./run-context.js";
|
|
6
|
-
import { getTemporalLogsDir
|
|
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
9
|
import { budgetCliArgs } from "@agent-dealer/shared";
|
|
10
|
+
import { buildClaudePhaseArgs } from "./claude-args.js";
|
|
10
11
|
async function spawnCli(cmd, args, cwd) {
|
|
11
12
|
return new Promise((resolve, reject) => {
|
|
12
13
|
const chunks = [];
|
|
@@ -25,14 +26,15 @@ function logPathFor(run, mode) {
|
|
|
25
26
|
const logDir = getTemporalLogsDir();
|
|
26
27
|
return path.join(logDir, `${run.id}-${mode}-${Date.now()}.ndjson`);
|
|
27
28
|
}
|
|
28
|
-
export async function runClaude(run, mode = "execute", model,
|
|
29
|
+
export async function runClaude(run, mode = "execute", model, opts) {
|
|
29
30
|
const mcpConfig = process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
|
|
30
31
|
if (!fs.existsSync(mcpConfig)) {
|
|
31
32
|
throw new Error(`MCP config not found: ${mcpConfig}. Set CLAUDE_MCP_CONFIG.`);
|
|
32
33
|
}
|
|
33
34
|
const phaseBudget = resolveBudgetForPhase(run, mode);
|
|
34
|
-
const resumeSessionId =
|
|
35
|
-
|
|
35
|
+
const resumeSessionId = opts?.resumeSessionId ??
|
|
36
|
+
(mode === "execute" && humanFeedbackText(run) ? lineageParentExecuteSessionId(run) : null);
|
|
37
|
+
const prompt = opts?.promptOverride ??
|
|
36
38
|
(mode === "plan"
|
|
37
39
|
? buildPlanPrompt(run)
|
|
38
40
|
: mode === "reflect"
|
|
@@ -49,19 +51,8 @@ export async function runClaude(run, mode = "execute", model, promptOverride) {
|
|
|
49
51
|
"--mcp-config",
|
|
50
52
|
mcpConfig,
|
|
51
53
|
...budgetCliArgs(phaseBudget),
|
|
52
|
-
|
|
53
|
-
"stream-json",
|
|
54
|
-
"--verbose",
|
|
54
|
+
...buildClaudePhaseArgs(run, mode),
|
|
55
55
|
];
|
|
56
|
-
if (mode === "plan") {
|
|
57
|
-
args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
58
|
-
}
|
|
59
|
-
else if (mode === "reflect") {
|
|
60
|
-
args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
args.push("--add-dir", getTemporalOutputDir(), "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
64
|
-
}
|
|
65
56
|
const { exitCode, transcript } = await spawnCli(resolveClaudeBin(), args, workspaceForRun(run));
|
|
66
57
|
if (transcript)
|
|
67
58
|
fs.writeFileSync(logPath, transcript);
|
|
@@ -85,12 +76,12 @@ export async function runCursor(run, mode = "execute", model) {
|
|
|
85
76
|
fs.writeFileSync(logPath, transcript);
|
|
86
77
|
return { exitCode, transcript, logPath };
|
|
87
78
|
}
|
|
88
|
-
export async function runAgent(run, mode = "execute") {
|
|
79
|
+
export async function runAgent(run, mode = "execute", revise) {
|
|
89
80
|
const fresh = getRun(run.id) ?? run;
|
|
90
81
|
const model = resolveModelForPhase(fresh, mode);
|
|
91
82
|
if (fresh.runtime === "cursor_local")
|
|
92
83
|
return runCursor(fresh, mode, model);
|
|
93
|
-
return runClaude(fresh, mode, model);
|
|
84
|
+
return runClaude(fresh, mode, model, revise ? { promptOverride: revise.prompt, resumeSessionId: revise.resumeSessionId } : undefined);
|
|
94
85
|
}
|
|
95
86
|
/** @deprecated use stream-json extractPlanMarkdown via persistRunOutput */
|
|
96
87
|
export function extractPlanFromTranscript(transcript) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildClaudePhaseArgs, DENY_SEND_TOOL } from "./claude-args.js";
|
|
4
|
+
const baseRun = {
|
|
5
|
+
id: "00000000-0000-4000-a000-000000000099",
|
|
6
|
+
source: "manual",
|
|
7
|
+
externalId: null,
|
|
8
|
+
externalLabel: null,
|
|
9
|
+
title: "Test",
|
|
10
|
+
description: null,
|
|
11
|
+
repo: null,
|
|
12
|
+
artifactWorkspace: null,
|
|
13
|
+
agentId: null,
|
|
14
|
+
agentName: null,
|
|
15
|
+
deckId: null,
|
|
16
|
+
deckName: null,
|
|
17
|
+
playbookId: null,
|
|
18
|
+
runtime: "claude_code",
|
|
19
|
+
planModel: null,
|
|
20
|
+
executeModel: null,
|
|
21
|
+
status: "running",
|
|
22
|
+
lineageId: null,
|
|
23
|
+
acceptanceCriteria: null,
|
|
24
|
+
approvalGatesJson: null,
|
|
25
|
+
budgetJson: null,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
function runWithCategory(taskCategory) {
|
|
30
|
+
return { ...baseRun, taskCategory };
|
|
31
|
+
}
|
|
32
|
+
function hasDisallowSend(args) {
|
|
33
|
+
const i = args.indexOf("--disallowedTools");
|
|
34
|
+
return i >= 0 && args[i + 1] === DENY_SEND_TOOL;
|
|
35
|
+
}
|
|
36
|
+
test("all phases deny call_service_tool", () => {
|
|
37
|
+
for (const mode of ["plan", "execute", "reflect"]) {
|
|
38
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), mode);
|
|
39
|
+
assert.equal(hasDisallowSend(args), true, mode);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
test("execute allowlist includes list_service_tools", () => {
|
|
43
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
|
|
44
|
+
const i = args.indexOf("--allowedTools");
|
|
45
|
+
assert.match(args[i + 1], /list_service_tools/);
|
|
46
|
+
});
|
|
47
|
+
test("communication and email execute omit Bash", () => {
|
|
48
|
+
for (const cat of ["communication", "email"]) {
|
|
49
|
+
const args = buildClaudePhaseArgs(runWithCategory(cat), "execute");
|
|
50
|
+
const i = args.indexOf("--allowedTools");
|
|
51
|
+
assert.doesNotMatch(args[i + 1], /\bBash\b/);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
test("code execute retains Bash", () => {
|
|
55
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
|
|
56
|
+
const i = args.indexOf("--allowedTools");
|
|
57
|
+
assert.match(args[i + 1], /\bBash\b/);
|
|
58
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { detectExecutionBlocker } from "@agent-dealer/shared";
|
|
4
|
-
import { addArtifact, getLatestArtifact, getLineageParentRun } from "../repository/runs.js";
|
|
3
|
+
import { detectExecutionBlocker, outboundDraftKind } from "@agent-dealer/shared";
|
|
4
|
+
import { addArtifact, appendEvent, getLatestArtifact, getLineageParentRun, resolveBudgetForPhase } from "../repository/runs.js";
|
|
5
5
|
import { getTemporalOutputDir } from "../paths.js";
|
|
6
|
-
import { buildStreamTrace, extractPlanMarkdown, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
|
|
6
|
+
import { buildStreamTrace, extractOutboundDraft, extractPlanMarkdown, extractPlanTriage, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
|
|
7
7
|
import { humanFeedbackText } from "./run-context.js";
|
|
8
8
|
export function seedDeliverableFromParent(run) {
|
|
9
9
|
const parent = getLineageParentRun(run);
|
|
@@ -44,11 +44,20 @@ export function persistRunOutput(input) {
|
|
|
44
44
|
const raw = input.rawTranscript ?? (fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "");
|
|
45
45
|
const events = parseNdjson(raw);
|
|
46
46
|
const sessionId = extractSessionId(events);
|
|
47
|
-
|
|
47
|
+
let resultText = extractResultText(events);
|
|
48
|
+
const outboundExtract = phase === "execute" ? extractOutboundDraft(resultText ?? "") : null;
|
|
49
|
+
if (outboundExtract) {
|
|
50
|
+
resultText = outboundExtract.markdown || resultText;
|
|
51
|
+
}
|
|
48
52
|
const streamError = extractResultIsError(events);
|
|
49
53
|
const blocker = detectExecutionBlocker(resultText);
|
|
50
54
|
const isError = exitCode !== 0 || streamError || blocker.detected;
|
|
51
55
|
const usage = extractUsage(events, phase, runtime);
|
|
56
|
+
const caps = resolveBudgetForPhase(run, phase);
|
|
57
|
+
if (caps?.maxTurns != null)
|
|
58
|
+
usage.maxTurns = caps.maxTurns;
|
|
59
|
+
if (caps?.maxBudgetUsd != null)
|
|
60
|
+
usage.maxBudgetUsd = caps.maxBudgetUsd;
|
|
52
61
|
const trace = buildStreamTrace(events);
|
|
53
62
|
if (sessionId) {
|
|
54
63
|
addArtifact(run.id, "agent_session", { phase, runtime, sessionId }, "agent");
|
|
@@ -63,15 +72,45 @@ export function persistRunOutput(input) {
|
|
|
63
72
|
blocker: blocker.detected ? { summary: blocker.summary } : undefined,
|
|
64
73
|
}, "agent");
|
|
65
74
|
addArtifact(run.id, "transcript", { phase, exitCode, excerpt: raw.slice(-20000), resultText: resultText?.slice(0, 8000) }, "agent", logPath);
|
|
66
|
-
if (phase === "execute") {
|
|
75
|
+
if (phase === "execute" && outboundExtract) {
|
|
67
76
|
captureDocumentArtifact(run, resultText);
|
|
77
|
+
persistOutboundDraft(run, outboundExtract);
|
|
68
78
|
}
|
|
69
79
|
if (phase === "plan") {
|
|
70
|
-
const
|
|
71
|
-
|
|
80
|
+
const rawPlan = extractPlanMarkdown(events);
|
|
81
|
+
const planTriage = extractPlanTriage(rawPlan);
|
|
82
|
+
return {
|
|
83
|
+
planMarkdown: planTriage.markdown,
|
|
84
|
+
planTriage,
|
|
85
|
+
resultText,
|
|
86
|
+
sessionId,
|
|
87
|
+
blocked: blocker.detected,
|
|
88
|
+
blockerSummary: blocker.summary,
|
|
89
|
+
};
|
|
72
90
|
}
|
|
73
91
|
return { resultText, sessionId, blocked: blocker.detected, blockerSummary: blocker.summary };
|
|
74
92
|
}
|
|
93
|
+
function persistOutboundDraft(run, outbound) {
|
|
94
|
+
if (!outbound.hadJsonBlock)
|
|
95
|
+
return;
|
|
96
|
+
if (outbound.invalid) {
|
|
97
|
+
appendEvent(run.id, "outbound_draft_invalid", { reason: "malformed block" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!outbound.draft)
|
|
101
|
+
return;
|
|
102
|
+
if (outbound.mismatch) {
|
|
103
|
+
appendEvent(run.id, "outbound_draft_mismatch", {
|
|
104
|
+
summaryBody: outbound.draft.summary.body,
|
|
105
|
+
toolCall: outbound.draft.toolCall,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
addArtifact(run.id, outboundDraftKind(outbound.draft.actionType), {
|
|
109
|
+
draft: outbound.draft,
|
|
110
|
+
status: "pending",
|
|
111
|
+
bodyMismatch: outbound.mismatch || undefined,
|
|
112
|
+
}, "agent");
|
|
113
|
+
}
|
|
75
114
|
function captureDocumentArtifact(run, resultText) {
|
|
76
115
|
const docPath = expectedDocPath(run);
|
|
77
116
|
if (fs.existsSync(docPath)) {
|