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
|
@@ -30,6 +30,21 @@ function artifactMarkdown(kind, runId) {
|
|
|
30
30
|
return "";
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
function outboundDraftContractSection() {
|
|
34
|
+
return [
|
|
35
|
+
"## Outbound actions (required when sending Slack or email)",
|
|
36
|
+
"Do NOT send Slack messages, post to chat, or send email during execution — call_service_tool is blocked.",
|
|
37
|
+
"If the task requires an outward-facing message, end your reply with exactly one fenced ```json block shaped like:",
|
|
38
|
+
'{"actionType":"slack_message"|"email","summary":{"target":"#channel or recipient","body":"exact message text"},"toolCall":{"serviceName":"<deck service UUID>","toolName":"<tool from list_service_tools>","arguments":{...}}}',
|
|
39
|
+
"Rules:",
|
|
40
|
+
"- At most one outbound draft block per run.",
|
|
41
|
+
"- Call bind_workspace, then get_bound_deck and list_service_tools — never invent a service UUID or tool name.",
|
|
42
|
+
"- toolCall.serviceName must be the exact Slack/email service id from the bound deck (not a display name).",
|
|
43
|
+
"- Slack: toolName is slack_send_message; arguments.channel_id is the user or channel id from slack_search_users (never guess IDs from meeting notes); arguments.message must byte-match summary.body.",
|
|
44
|
+
"- Email: use the real tool name and message field from list_service_tools; message body must byte-match summary.body.",
|
|
45
|
+
"- Do not perform the send — the human approves and the server delivers verbatim.",
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
33
48
|
export function buildExecutionContinuationPrompt(run) {
|
|
34
49
|
const ctx = retryContext(run);
|
|
35
50
|
const parts = [
|
|
@@ -55,6 +70,7 @@ export function buildExecutionContinuationPrompt(run) {
|
|
|
55
70
|
parts.push(`Then get_playbook("${run.playbookId}") and follow it.`);
|
|
56
71
|
}
|
|
57
72
|
}
|
|
73
|
+
parts.push(outboundDraftContractSection());
|
|
58
74
|
return parts.join("\n");
|
|
59
75
|
}
|
|
60
76
|
export function buildExecutionPrompt(run) {
|
|
@@ -87,6 +103,7 @@ export function buildExecutionPrompt(run) {
|
|
|
87
103
|
parts.push(`## Human feedback`, feedback, ``);
|
|
88
104
|
}
|
|
89
105
|
}
|
|
106
|
+
parts.push(...planAnswersSections(run));
|
|
90
107
|
if (run.taskCategory === "content" || run.taskCategory === "research") {
|
|
91
108
|
parts.push(`## Deliverable`, documentOutputHint(run), `You MUST write the file using your Write/edit tool — do not only print markdown in chat.`, ``);
|
|
92
109
|
}
|
|
@@ -96,6 +113,7 @@ export function buildExecutionPrompt(run) {
|
|
|
96
113
|
parts.push(`Then get_playbook("${run.playbookId}") and follow it.`);
|
|
97
114
|
}
|
|
98
115
|
}
|
|
116
|
+
parts.push(outboundDraftContractSection());
|
|
99
117
|
return parts.join("\n");
|
|
100
118
|
}
|
|
101
119
|
export function buildReflectPrompt(run, opts) {
|
|
@@ -137,6 +155,68 @@ export function buildReflectPrompt(run, opts) {
|
|
|
137
155
|
}
|
|
138
156
|
return parts.join("\n");
|
|
139
157
|
}
|
|
158
|
+
function hasOutboundSendGate(category) {
|
|
159
|
+
return category === "communication" || category === "email";
|
|
160
|
+
}
|
|
161
|
+
function planTriageContractSection(taskCategory = "other") {
|
|
162
|
+
const sendGate = hasOutboundSendGate(taskCategory);
|
|
163
|
+
const rules = [
|
|
164
|
+
"## Required final JSON block",
|
|
165
|
+
"After the plan markdown, end your reply with exactly one fenced ```json block shaped like:",
|
|
166
|
+
'{"verdict":"trivial"|"needs_review","rationale":"one sentence","questions":[{"id":"q1","question":"...","options":[{"label":"...","description":"..."}]}]}',
|
|
167
|
+
"Rules:",
|
|
168
|
+
sendGate
|
|
169
|
+
? '- "trivial" means safe to execute without human plan review; it requires an empty questions array. Use "trivial" when the task names recipient and message (or execution steps are obvious). The send gate reviews the exact payload before anything is sent.'
|
|
170
|
+
: '- "trivial" means this plan is safe to execute without human review; it requires an empty questions array. When in doubt, use "needs_review".',
|
|
171
|
+
];
|
|
172
|
+
if (sendGate) {
|
|
173
|
+
rules.push("- Do not ask plan questions about recipient, channel, or message wording — those are confirmed when the human approves the outbound draft.", "- Ask at most 3 questions, and only when a missing decision would change how you execute (not send approval). Never ask permission to proceed.");
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
rules.push("- Ask at most 3 questions, and only when the answer changes how you would execute. Never ask permission to proceed.");
|
|
177
|
+
}
|
|
178
|
+
rules.push("- Each question needs 2-4 concrete options; give each option a short description.");
|
|
179
|
+
return rules.join("\n");
|
|
180
|
+
}
|
|
181
|
+
function planAnswersSections(run) {
|
|
182
|
+
const ansArt = getLatestArtifact(run.id, "plan_answers");
|
|
183
|
+
if (!ansArt?.contentJson)
|
|
184
|
+
return [];
|
|
185
|
+
try {
|
|
186
|
+
const ans = JSON.parse(ansArt.contentJson);
|
|
187
|
+
if (ans.outcome !== "approved" || ans.answers.length === 0)
|
|
188
|
+
return [];
|
|
189
|
+
const triArt = getLatestArtifact(run.id, "plan_triage");
|
|
190
|
+
const questions = triArt?.contentJson
|
|
191
|
+
? JSON.parse(triArt.contentJson).questions
|
|
192
|
+
: [];
|
|
193
|
+
const lines = ans.answers.map((a) => {
|
|
194
|
+
const q = questions.find((x) => x.id === a.questionId);
|
|
195
|
+
return `- ${q?.question ?? a.questionId}: **${a.selectedLabel ?? a.freeText ?? ""}**`;
|
|
196
|
+
});
|
|
197
|
+
return ["## Human answers to plan questions", ...lines, ""];
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function buildPlanRevisePrompt(run, questions, answers) {
|
|
204
|
+
const qa = answers.map((a) => {
|
|
205
|
+
const q = questions.find((x) => x.id === a.questionId);
|
|
206
|
+
return `- Q: ${q?.question ?? a.questionId}\n A: ${a.selectedLabel ?? a.freeText ?? ""}`;
|
|
207
|
+
});
|
|
208
|
+
return [
|
|
209
|
+
"The human answered your plan questions. Revise the plan accordingly — do not execute anything.",
|
|
210
|
+
"",
|
|
211
|
+
"## Task",
|
|
212
|
+
taskText(run),
|
|
213
|
+
"",
|
|
214
|
+
"## Answers",
|
|
215
|
+
...qa,
|
|
216
|
+
"",
|
|
217
|
+
planTriageContractSection(run.taskCategory),
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
140
220
|
export function buildPlanPrompt(run) {
|
|
141
221
|
const parts = [
|
|
142
222
|
`Draft a concise execution plan (markdown) for this task.`,
|
|
@@ -159,6 +239,12 @@ export function buildPlanPrompt(run) {
|
|
|
159
239
|
if (run.taskCategory === "content" || run.taskCategory === "research") {
|
|
160
240
|
parts.push(`## Deliverable (execution phase only)`, `After approval, execution will produce a markdown document. Plan the steps; do not create the file now.`, ``);
|
|
161
241
|
}
|
|
162
|
-
|
|
242
|
+
if (hasOutboundSendGate(run.taskCategory)) {
|
|
243
|
+
parts.push(`## Outbound send gate`, `Execution drafts the Slack/email payload; the human approves the exact message before send.`, `If the task text names who to message and what to say (or the intent is obvious), plan for verdict "trivial" with no questions.`, ``);
|
|
244
|
+
parts.push(`Output a brief bullet plan (3–5 steps). Skip a risks section unless execution is blocked.`, ``, planTriageContractSection(run.taskCategory));
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
parts.push(`Output a step-by-step plan with risks.`, ``, planTriageContractSection(run.taskCategory));
|
|
248
|
+
}
|
|
163
249
|
return parts.join("\n");
|
|
164
250
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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-prompts-"));
|
|
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 } = await import("../repository/runs.js");
|
|
12
|
+
const { buildExecutionPrompt, buildPlanPrompt, buildPlanRevisePrompt } = await import("./prompts.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{
|
|
15
|
+
id: "q1",
|
|
16
|
+
question: "Which storage backend?",
|
|
17
|
+
options: [{ label: "SQLite" }, { label: "Postgres" }],
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
before(() => {
|
|
21
|
+
migrate();
|
|
22
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
23
|
+
});
|
|
24
|
+
function makeRun() {
|
|
25
|
+
return createRun({
|
|
26
|
+
title: "Prompt test task",
|
|
27
|
+
taskCategory: "other",
|
|
28
|
+
status: "plan_pending",
|
|
29
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
test("plan prompt includes the triage contract", () => {
|
|
33
|
+
const prompt = buildPlanPrompt(makeRun());
|
|
34
|
+
assert.match(prompt, /"verdict"/);
|
|
35
|
+
assert.match(prompt, /needs_review/);
|
|
36
|
+
assert.match(prompt, /at most 3 questions/i);
|
|
37
|
+
});
|
|
38
|
+
test("communication plan prompt favors trivial triage and defers send details to send gate", () => {
|
|
39
|
+
const run = createRun({
|
|
40
|
+
title: "Send a test message to Yusuke",
|
|
41
|
+
description: "Say hello from agent-dealer",
|
|
42
|
+
taskCategory: "communication",
|
|
43
|
+
status: "plan_pending",
|
|
44
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
45
|
+
});
|
|
46
|
+
const prompt = buildPlanPrompt(run);
|
|
47
|
+
assert.match(prompt, /Outbound send gate/);
|
|
48
|
+
assert.match(prompt, /send gate reviews the exact payload/i);
|
|
49
|
+
assert.match(prompt, /Do not ask plan questions about recipient/i);
|
|
50
|
+
assert.doesNotMatch(prompt, /When in doubt, use "needs_review"/);
|
|
51
|
+
assert.match(prompt, /brief bullet plan/i);
|
|
52
|
+
assert.doesNotMatch(prompt, /step-by-step plan with risks/);
|
|
53
|
+
});
|
|
54
|
+
test("other category plan prompt keeps conservative when-in-doubt rule", () => {
|
|
55
|
+
const prompt = buildPlanPrompt(makeRun());
|
|
56
|
+
assert.match(prompt, /When in doubt, use "needs_review"/);
|
|
57
|
+
assert.match(prompt, /step-by-step plan with risks/);
|
|
58
|
+
});
|
|
59
|
+
test("execution prompt renders human answers as Q→A pairs", () => {
|
|
60
|
+
const run = makeRun();
|
|
61
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
62
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
63
|
+
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
64
|
+
addArtifact(run.id, "plan_answers", {
|
|
65
|
+
answers: [{ questionId: "q1", selectedLabel: "SQLite" }],
|
|
66
|
+
outcome: "approved",
|
|
67
|
+
answeredAt: new Date().toISOString(),
|
|
68
|
+
}, "human");
|
|
69
|
+
const prompt = buildExecutionPrompt(run);
|
|
70
|
+
assert.match(prompt, /## Human answers to plan questions/);
|
|
71
|
+
assert.match(prompt, /Which storage backend\?.*SQLite/s);
|
|
72
|
+
});
|
|
73
|
+
test("execution prompt omits answers section when none exist", () => {
|
|
74
|
+
const run = makeRun();
|
|
75
|
+
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
76
|
+
assert.doesNotMatch(buildExecutionPrompt(run), /Human answers to plan questions/);
|
|
77
|
+
});
|
|
78
|
+
test("revise prompt pairs each answer with its question and re-states the contract", () => {
|
|
79
|
+
const run = makeRun();
|
|
80
|
+
const prompt = buildPlanRevisePrompt(run, QUESTIONS, [{ questionId: "q1", freeText: "Use flat files instead" }]);
|
|
81
|
+
assert.match(prompt, /Which storage backend\?/);
|
|
82
|
+
assert.match(prompt, /Use flat files instead/);
|
|
83
|
+
assert.match(prompt, /"verdict"/);
|
|
84
|
+
});
|
|
85
|
+
test("execution prompt includes outbound draft contract", () => {
|
|
86
|
+
const run = makeRun();
|
|
87
|
+
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
88
|
+
const prompt = buildExecutionPrompt(run);
|
|
89
|
+
assert.match(prompt, /Outbound actions/);
|
|
90
|
+
assert.match(prompt, /call_service_tool is blocked/);
|
|
91
|
+
assert.match(prompt, /"actionType"/);
|
|
92
|
+
});
|
|
@@ -31,7 +31,7 @@ export async function runReflect(run, opts) {
|
|
|
31
31
|
try {
|
|
32
32
|
const playbook = await fetchPlaybook(run.playbookId);
|
|
33
33
|
const previousBody = playbook.body ?? "";
|
|
34
|
-
const result = await runClaude(run, "reflect", undefined, buildReflectPrompt(run, opts));
|
|
34
|
+
const result = await runClaude(run, "reflect", undefined, { promptOverride: buildReflectPrompt(run, opts) });
|
|
35
35
|
const events = parseNdjson(result.transcript);
|
|
36
36
|
const resultText = extractResultText(events) ?? result.transcript;
|
|
37
37
|
const proposal = parseReflectProposal(resultText);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { OutboundDraftBlock, outboundMessageMatchesSummary, PlanTriageBlock } from "@agent-dealer/shared";
|
|
2
3
|
export function parseNdjson(raw) {
|
|
3
4
|
const events = [];
|
|
4
5
|
for (const line of raw.split("\n")) {
|
|
@@ -62,6 +63,56 @@ export function extractPlanMarkdown(events) {
|
|
|
62
63
|
const raw = extractResultText(events) ?? "";
|
|
63
64
|
return stripMarkdownFences(raw);
|
|
64
65
|
}
|
|
66
|
+
const TRIAGE_FALLBACK = {
|
|
67
|
+
verdict: "needs_review",
|
|
68
|
+
rationale: "Agent did not return a valid triage block",
|
|
69
|
+
questions: [],
|
|
70
|
+
parseFallback: true,
|
|
71
|
+
};
|
|
72
|
+
/** Parse the trailing fenced json triage block from plan markdown (PRD F1.2). */
|
|
73
|
+
export function extractPlanTriage(planMarkdown) {
|
|
74
|
+
const blocks = [...planMarkdown.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
|
|
75
|
+
const last = blocks[blocks.length - 1];
|
|
76
|
+
if (!last)
|
|
77
|
+
return { markdown: planMarkdown.trim(), ...TRIAGE_FALLBACK };
|
|
78
|
+
const strippedMarkdown = (planMarkdown.slice(0, last.index) + planMarkdown.slice(last.index + last[0].length)).trim();
|
|
79
|
+
try {
|
|
80
|
+
const parsed = PlanTriageBlock.parse(JSON.parse(last[1]));
|
|
81
|
+
return {
|
|
82
|
+
markdown: strippedMarkdown,
|
|
83
|
+
verdict: parsed.verdict,
|
|
84
|
+
rationale: parsed.rationale,
|
|
85
|
+
questions: parsed.questions,
|
|
86
|
+
parseFallback: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { markdown: strippedMarkdown, ...TRIAGE_FALLBACK };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Parse the trailing fenced json outbound draft block from execute result (PRD F2.2). */
|
|
94
|
+
export function extractOutboundDraft(resultMarkdown) {
|
|
95
|
+
const blocks = [...resultMarkdown.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
|
|
96
|
+
const last = blocks[blocks.length - 1];
|
|
97
|
+
if (!last) {
|
|
98
|
+
return { markdown: resultMarkdown.trim(), hadJsonBlock: false, invalid: false, mismatch: false };
|
|
99
|
+
}
|
|
100
|
+
const strippedMarkdown = (resultMarkdown.slice(0, last.index) + resultMarkdown.slice(last.index + last[0].length)).trim();
|
|
101
|
+
try {
|
|
102
|
+
const parsed = OutboundDraftBlock.parse(JSON.parse(last[1]));
|
|
103
|
+
const mismatch = !outboundMessageMatchesSummary(parsed.toolCall, parsed.summary.body);
|
|
104
|
+
return {
|
|
105
|
+
markdown: strippedMarkdown,
|
|
106
|
+
draft: parsed,
|
|
107
|
+
hadJsonBlock: true,
|
|
108
|
+
invalid: false,
|
|
109
|
+
mismatch,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { markdown: strippedMarkdown, hadJsonBlock: true, invalid: true, mismatch: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
65
116
|
export function extractUsage(events, phase, runtime) {
|
|
66
117
|
const result = events.find((e) => e.type === "result");
|
|
67
118
|
const init = events.find((e) => e.type === "system");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { extractPlanTriage, extractOutboundDraft } from "./stream-json.js";
|
|
4
|
+
const PLAN = "# Plan\n1. Do the thing\n2. Verify";
|
|
5
|
+
const VALID_BLOCK = [
|
|
6
|
+
"```json",
|
|
7
|
+
JSON.stringify({
|
|
8
|
+
verdict: "needs_review",
|
|
9
|
+
rationale: "Two viable approaches",
|
|
10
|
+
questions: [
|
|
11
|
+
{
|
|
12
|
+
id: "q1",
|
|
13
|
+
question: "Which approach?",
|
|
14
|
+
options: [{ label: "A" }, { label: "B", description: "slower but safer" }],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
}),
|
|
18
|
+
"```",
|
|
19
|
+
].join("\n");
|
|
20
|
+
test("parses trailing triage block and strips it from the markdown", () => {
|
|
21
|
+
const r = extractPlanTriage(`${PLAN}\n\n${VALID_BLOCK}`);
|
|
22
|
+
assert.equal(r.parseFallback, false);
|
|
23
|
+
assert.equal(r.verdict, "needs_review");
|
|
24
|
+
assert.equal(r.questions.length, 1);
|
|
25
|
+
assert.equal(r.markdown, PLAN);
|
|
26
|
+
});
|
|
27
|
+
test("trivial block with no questions parses", () => {
|
|
28
|
+
const block = '```json\n{"verdict":"trivial","rationale":"one-line change"}\n```';
|
|
29
|
+
const r = extractPlanTriage(`${PLAN}\n\n${block}`);
|
|
30
|
+
assert.equal(r.verdict, "trivial");
|
|
31
|
+
assert.equal(r.parseFallback, false);
|
|
32
|
+
assert.deepEqual(r.questions, []);
|
|
33
|
+
});
|
|
34
|
+
test("missing block falls back to needs_review with full markdown kept", () => {
|
|
35
|
+
const r = extractPlanTriage(PLAN);
|
|
36
|
+
assert.equal(r.parseFallback, true);
|
|
37
|
+
assert.equal(r.verdict, "needs_review");
|
|
38
|
+
assert.equal(r.markdown, PLAN);
|
|
39
|
+
});
|
|
40
|
+
test("malformed JSON falls back and strips the fence", () => {
|
|
41
|
+
const r = extractPlanTriage(`${PLAN}\n\n\`\`\`json\n{not json}\n\`\`\``);
|
|
42
|
+
assert.equal(r.parseFallback, true);
|
|
43
|
+
assert.equal(r.markdown, PLAN);
|
|
44
|
+
assert.equal(r.markdown.includes("```json"), false);
|
|
45
|
+
});
|
|
46
|
+
test("last json block wins; earlier json-in-prose is ignored", () => {
|
|
47
|
+
const early = '```json\n{"some":"config"}\n```';
|
|
48
|
+
const r = extractPlanTriage(`${PLAN}\n\n${early}\n\nMore prose\n\n${VALID_BLOCK}`);
|
|
49
|
+
assert.equal(r.parseFallback, false);
|
|
50
|
+
assert.equal(r.markdown.includes('{"some":"config"}'), true);
|
|
51
|
+
assert.equal(r.markdown.includes('"verdict"'), false);
|
|
52
|
+
});
|
|
53
|
+
test("valid json block that is not a triage shape falls back and strips the fence", () => {
|
|
54
|
+
const r = extractPlanTriage(`${PLAN}\n\n\`\`\`json\n{"foo":"bar"}\n\`\`\``);
|
|
55
|
+
assert.equal(r.parseFallback, true);
|
|
56
|
+
assert.equal(r.markdown, PLAN);
|
|
57
|
+
});
|
|
58
|
+
const VALID_DRAFT = {
|
|
59
|
+
actionType: "slack_message",
|
|
60
|
+
summary: { target: "#test", body: "Hello" },
|
|
61
|
+
toolCall: {
|
|
62
|
+
serviceName: "svc-1",
|
|
63
|
+
toolName: "chat_postMessage",
|
|
64
|
+
arguments: { channel: "C1", text: "Hello" },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const DRAFT_BLOCK = [
|
|
68
|
+
"```json",
|
|
69
|
+
JSON.stringify(VALID_DRAFT),
|
|
70
|
+
"```",
|
|
71
|
+
].join("\n");
|
|
72
|
+
test("extractOutboundDraft parses valid block and strips markdown", () => {
|
|
73
|
+
const r = extractOutboundDraft(`Done.\n\n${DRAFT_BLOCK}`);
|
|
74
|
+
assert.equal(r.invalid, false);
|
|
75
|
+
assert.equal(r.draft?.actionType, "slack_message");
|
|
76
|
+
assert.equal(r.markdown, "Done.");
|
|
77
|
+
});
|
|
78
|
+
test("extractOutboundDraft absent block leaves markdown", () => {
|
|
79
|
+
const r = extractOutboundDraft("plain result");
|
|
80
|
+
assert.equal(r.hadJsonBlock, false);
|
|
81
|
+
assert.equal(r.markdown, "plain result");
|
|
82
|
+
});
|
|
83
|
+
test("extractOutboundDraft malformed json marks invalid", () => {
|
|
84
|
+
const r = extractOutboundDraft(`x\n\n\`\`\`json\n{bad}\n\`\`\``);
|
|
85
|
+
assert.equal(r.invalid, true);
|
|
86
|
+
});
|
|
87
|
+
test("extractOutboundDraft detects body mismatch", () => {
|
|
88
|
+
const bad = {
|
|
89
|
+
...VALID_DRAFT,
|
|
90
|
+
toolCall: { ...VALID_DRAFT.toolCall, arguments: { channel: "C1", text: "Other" } },
|
|
91
|
+
};
|
|
92
|
+
const r = extractOutboundDraft(`\`\`\`json\n${JSON.stringify(bad)}\n\`\`\``);
|
|
93
|
+
assert.equal(r.mismatch, true);
|
|
94
|
+
assert.ok(r.draft);
|
|
95
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UsageContent as UsageContentSchema } from "@agent-dealer/shared";
|
|
2
|
-
import { listArtifacts, listLineageRuns } from "./repository/runs.js";
|
|
2
|
+
import { listArtifacts, listLineageRuns, resolveBudgetForPhase } from "./repository/runs.js";
|
|
3
3
|
function parseUsage(contentJson) {
|
|
4
4
|
try {
|
|
5
5
|
return UsageContentSchema.parse(JSON.parse(contentJson));
|
|
@@ -17,13 +17,14 @@ function labelUsage(lineageIdx, phase, planCount, execCount) {
|
|
|
17
17
|
}
|
|
18
18
|
return lineageIdx === 1 ? "retry" : `retry ${lineageIdx}`;
|
|
19
19
|
}
|
|
20
|
-
export function buildLineageUsageSummary(run) {
|
|
20
|
+
export function buildLineageUsageSummary(run, opts) {
|
|
21
21
|
const lineageRuns = listLineageRuns(run);
|
|
22
22
|
const lines = [];
|
|
23
23
|
lineageRuns.forEach((lr, lineageIdx) => {
|
|
24
24
|
let planCount = 0;
|
|
25
25
|
let execCount = 0;
|
|
26
|
-
|
|
26
|
+
const arts = opts?.artifactsByRunId?.[lr.id] ?? listArtifacts(lr.id);
|
|
27
|
+
for (const art of arts) {
|
|
27
28
|
if (art.kind !== "usage" || !art.contentJson)
|
|
28
29
|
continue;
|
|
29
30
|
const usage = parseUsage(art.contentJson);
|
|
@@ -33,9 +34,12 @@ export function buildLineageUsageSummary(run) {
|
|
|
33
34
|
planCount++;
|
|
34
35
|
else
|
|
35
36
|
execCount++;
|
|
37
|
+
const resolved = resolveBudgetForPhase(lr, usage.phase);
|
|
36
38
|
lines.push({
|
|
37
39
|
label: labelUsage(lineageIdx, usage.phase, planCount, execCount),
|
|
38
40
|
usage,
|
|
41
|
+
maxTurns: usage.maxTurns ?? resolved?.maxTurns,
|
|
42
|
+
maxBudgetUsd: usage.maxBudgetUsd ?? resolved?.maxBudgetUsd,
|
|
39
43
|
});
|
|
40
44
|
}
|
|
41
45
|
});
|
|
@@ -44,6 +48,7 @@ export function buildLineageUsageSummary(run) {
|
|
|
44
48
|
inputTokens: acc.inputTokens + (usage.inputTokens ?? 0),
|
|
45
49
|
outputTokens: acc.outputTokens + (usage.outputTokens ?? 0),
|
|
46
50
|
durationMs: acc.durationMs + (usage.durationMs ?? 0),
|
|
47
|
-
|
|
51
|
+
numTurns: acc.numTurns + (usage.numTurns ?? 0),
|
|
52
|
+
}), { totalCostUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0, numTurns: 0 });
|
|
48
53
|
return { lines, total };
|
|
49
54
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"@agent-dealer/shared": "*",
|
|
25
25
|
"@fastify/cors": "^11.0.1",
|
|
26
26
|
"@fastify/static": "^8.2.0",
|
|
27
|
-
"
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
+
"better-sqlite3": "^12.11.1",
|
|
28
29
|
"dotenv": "^16.5.0",
|
|
29
30
|
"fastify": "^5.3.3",
|
|
30
31
|
"uuid": "^11.1.0"
|