agent-dealer 0.1.3 → 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/approve-deliver.js +62 -0
- package/bundle/server/dist/queue/dispatcher.js +12 -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/routes/index.js +7 -9
- package/bundle/server/dist/runners/claude-args.js +24 -0
- package/bundle/server/dist/runners/claude.js +3 -13
- package/bundle/server/dist/runners/claude.test.js +58 -0
- package/bundle/server/dist/runners/persist.js +31 -5
- package/bundle/server/dist/runners/prompts.js +43 -8
- package/bundle/server/dist/runners/prompts.test.js +29 -0
- package/bundle/server/dist/runners/stream-json.js +24 -1
- package/bundle/server/dist/runners/stream-json.test.js +39 -1
- 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/index.d.ts +53 -46
- package/bundle/shared/dist/index.js +4 -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/package.json +1 -1
- package/package.json +3 -2
- package/bundle/server/static-ui/assets/index-CH7u5bXC.js +0 -78
- package/bundle/server/static-ui/assets/index-CKuYOeDN.css +0 -1
|
@@ -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, resolveBudgetForPhase } 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, extractPlanTriage, 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,7 +44,11 @@ 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;
|
|
@@ -68,8 +72,9 @@ export function persistRunOutput(input) {
|
|
|
68
72
|
blocker: blocker.detected ? { summary: blocker.summary } : undefined,
|
|
69
73
|
}, "agent");
|
|
70
74
|
addArtifact(run.id, "transcript", { phase, exitCode, excerpt: raw.slice(-20000), resultText: resultText?.slice(0, 8000) }, "agent", logPath);
|
|
71
|
-
if (phase === "execute") {
|
|
75
|
+
if (phase === "execute" && outboundExtract) {
|
|
72
76
|
captureDocumentArtifact(run, resultText);
|
|
77
|
+
persistOutboundDraft(run, outboundExtract);
|
|
73
78
|
}
|
|
74
79
|
if (phase === "plan") {
|
|
75
80
|
const rawPlan = extractPlanMarkdown(events);
|
|
@@ -85,6 +90,27 @@ export function persistRunOutput(input) {
|
|
|
85
90
|
}
|
|
86
91
|
return { resultText, sessionId, blocked: blocker.detected, blockerSummary: blocker.summary };
|
|
87
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
|
+
}
|
|
88
114
|
function captureDocumentArtifact(run, resultText) {
|
|
89
115
|
const docPath = expectedDocPath(run);
|
|
90
116
|
if (fs.existsSync(docPath)) {
|
|
@@ -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) {
|
|
@@ -97,6 +113,7 @@ export function buildExecutionPrompt(run) {
|
|
|
97
113
|
parts.push(`Then get_playbook("${run.playbookId}") and follow it.`);
|
|
98
114
|
}
|
|
99
115
|
}
|
|
116
|
+
parts.push(outboundDraftContractSection());
|
|
100
117
|
return parts.join("\n");
|
|
101
118
|
}
|
|
102
119
|
export function buildReflectPrompt(run, opts) {
|
|
@@ -138,16 +155,28 @@ export function buildReflectPrompt(run, opts) {
|
|
|
138
155
|
}
|
|
139
156
|
return parts.join("\n");
|
|
140
157
|
}
|
|
141
|
-
function
|
|
142
|
-
return
|
|
158
|
+
function hasOutboundSendGate(category) {
|
|
159
|
+
return category === "communication" || category === "email";
|
|
160
|
+
}
|
|
161
|
+
function planTriageContractSection(taskCategory = "other") {
|
|
162
|
+
const sendGate = hasOutboundSendGate(taskCategory);
|
|
163
|
+
const rules = [
|
|
143
164
|
"## Required final JSON block",
|
|
144
165
|
"After the plan markdown, end your reply with exactly one fenced ```json block shaped like:",
|
|
145
166
|
'{"verdict":"trivial"|"needs_review","rationale":"one sentence","questions":[{"id":"q1","question":"...","options":[{"label":"...","description":"..."}]}]}',
|
|
146
167
|
"Rules:",
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
]
|
|
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");
|
|
151
180
|
}
|
|
152
181
|
function planAnswersSections(run) {
|
|
153
182
|
const ansArt = getLatestArtifact(run.id, "plan_answers");
|
|
@@ -185,7 +214,7 @@ export function buildPlanRevisePrompt(run, questions, answers) {
|
|
|
185
214
|
"## Answers",
|
|
186
215
|
...qa,
|
|
187
216
|
"",
|
|
188
|
-
planTriageContractSection(),
|
|
217
|
+
planTriageContractSection(run.taskCategory),
|
|
189
218
|
].join("\n");
|
|
190
219
|
}
|
|
191
220
|
export function buildPlanPrompt(run) {
|
|
@@ -210,6 +239,12 @@ export function buildPlanPrompt(run) {
|
|
|
210
239
|
if (run.taskCategory === "content" || run.taskCategory === "research") {
|
|
211
240
|
parts.push(`## Deliverable (execution phase only)`, `After approval, execution will produce a markdown document. Plan the steps; do not create the file now.`, ``);
|
|
212
241
|
}
|
|
213
|
-
|
|
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
|
+
}
|
|
214
249
|
return parts.join("\n");
|
|
215
250
|
}
|
|
@@ -35,6 +35,27 @@ test("plan prompt includes the triage contract", () => {
|
|
|
35
35
|
assert.match(prompt, /needs_review/);
|
|
36
36
|
assert.match(prompt, /at most 3 questions/i);
|
|
37
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
|
+
});
|
|
38
59
|
test("execution prompt renders human answers as Q→A pairs", () => {
|
|
39
60
|
const run = makeRun();
|
|
40
61
|
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
@@ -61,3 +82,11 @@ test("revise prompt pairs each answer with its question and re-states the contra
|
|
|
61
82
|
assert.match(prompt, /Use flat files instead/);
|
|
62
83
|
assert.match(prompt, /"verdict"/);
|
|
63
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
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { PlanTriageBlock } from "@agent-dealer/shared";
|
|
2
|
+
import { OutboundDraftBlock, outboundMessageMatchesSummary, PlanTriageBlock } from "@agent-dealer/shared";
|
|
3
3
|
export function parseNdjson(raw) {
|
|
4
4
|
const events = [];
|
|
5
5
|
for (const line of raw.split("\n")) {
|
|
@@ -90,6 +90,29 @@ export function extractPlanTriage(planMarkdown) {
|
|
|
90
90
|
return { markdown: strippedMarkdown, ...TRIAGE_FALLBACK };
|
|
91
91
|
}
|
|
92
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
|
+
}
|
|
93
116
|
export function extractUsage(events, phase, runtime) {
|
|
94
117
|
const result = events.find((e) => e.type === "result");
|
|
95
118
|
const init = events.find((e) => e.type === "system");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { extractPlanTriage } from "./stream-json.js";
|
|
3
|
+
import { extractPlanTriage, extractOutboundDraft } from "./stream-json.js";
|
|
4
4
|
const PLAN = "# Plan\n1. Do the thing\n2. Verify";
|
|
5
5
|
const VALID_BLOCK = [
|
|
6
6
|
"```json",
|
|
@@ -55,3 +55,41 @@ test("valid json block that is not a triage shape falls back and strips the fenc
|
|
|
55
55
|
assert.equal(r.parseFallback, true);
|
|
56
56
|
assert.equal(r.markdown, PLAN);
|
|
57
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,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"
|