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.
Files changed (29) hide show
  1. package/bundle/server/dist/adapters/agent-deck-deliver.test.js +52 -0
  2. package/bundle/server/dist/adapters/agent-deck.js +140 -0
  3. package/bundle/server/dist/queue/approve-deliver.js +62 -0
  4. package/bundle/server/dist/queue/dispatcher.js +12 -0
  5. package/bundle/server/dist/queue/send-gate.test.js +108 -0
  6. package/bundle/server/dist/repository/outbound-drafts.js +62 -0
  7. package/bundle/server/dist/routes/index.js +7 -9
  8. package/bundle/server/dist/runners/claude-args.js +24 -0
  9. package/bundle/server/dist/runners/claude.js +3 -13
  10. package/bundle/server/dist/runners/claude.test.js +58 -0
  11. package/bundle/server/dist/runners/persist.js +31 -5
  12. package/bundle/server/dist/runners/prompts.js +43 -8
  13. package/bundle/server/dist/runners/prompts.test.js +29 -0
  14. package/bundle/server/dist/runners/stream-json.js +24 -1
  15. package/bundle/server/dist/runners/stream-json.test.js +39 -1
  16. package/bundle/server/package.json +3 -2
  17. package/bundle/server/static-ui/assets/index-Cc-h06U6.js +78 -0
  18. package/bundle/server/static-ui/assets/index-DKnME33I.css +1 -0
  19. package/bundle/server/static-ui/index.html +2 -2
  20. package/bundle/shared/dist/index.d.ts +53 -46
  21. package/bundle/shared/dist/index.js +4 -0
  22. package/bundle/shared/dist/outbound-draft.d.ts +192 -0
  23. package/bundle/shared/dist/outbound-draft.js +46 -0
  24. package/bundle/shared/dist/outbound-draft.test.d.ts +1 -0
  25. package/bundle/shared/dist/outbound-draft.test.js +34 -0
  26. package/bundle/shared/package.json +1 -1
  27. package/package.json +3 -2
  28. package/bundle/server/static-ui/assets/index-CH7u5bXC.js +0 -78
  29. 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
- const resultText = extractResultText(events);
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 planTriageContractSection() {
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
- '- "trivial" means this plan is safe to execute without human review; it requires an empty questions array. When in doubt, use "needs_review".',
148
- "- Ask at most 3 questions, and only when the answer changes how you would execute. Never ask permission to proceed.",
149
- "- Each question needs 2-4 concrete options; give each option a short description.",
150
- ].join("\n");
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
- parts.push(`Output a step-by-step plan with risks.`, ``, planTriageContractSection());
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",
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
- "better-sqlite3": "^11.9.1",
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"