@extrovert.dev/mcp 0.1.0-pre.3
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/LICENSE +21 -0
- package/README.md +313 -0
- package/dist/bin.d.ts +13 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +89 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +956 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1354 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +62 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +78 -0
- package/dist/contract.js.map +1 -0
- package/dist/extract.d.ts +25 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +131 -0
- package/dist/extract.js.map +1 -0
- package/dist/fixtures.d.ts +676 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +2685 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/http.d.ts +18 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +124 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +36 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.d.ts +8 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +22 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +2752 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +1037 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +83 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,2752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extrovert MCP tool definitions (spec §8).
|
|
3
|
+
*
|
|
4
|
+
* Each tool is a plain object describing its name, a model-facing description,
|
|
5
|
+
* a zod `inputSchema` (raw shape), behavioural annotations, and a handler that
|
|
6
|
+
* calls the typed `ExtrovertClient`. `registerTools` wires them onto an
|
|
7
|
+
* `McpServer`. Keeping the definitions data-first (rather than imperative
|
|
8
|
+
* `server.registerTool(...)` calls scattered around) makes the toolset easy to
|
|
9
|
+
* audit and reuse across the stdio and HTTP transports.
|
|
10
|
+
*
|
|
11
|
+
* Auth model (spec §14): the host supplies a SCOPED agent key via env — never
|
|
12
|
+
* an org-wide master key. `redeem_enrollment` lets an agent exchange a
|
|
13
|
+
* single-use enrollment token for that scoped key at runtime.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { ExtrovertApiError } from "./client.js";
|
|
17
|
+
import { isTerminalReviewEvent } from "./types.js";
|
|
18
|
+
/**
|
|
19
|
+
* Bind a tool's `Shape` at definition time so registration is fully typed per
|
|
20
|
+
* tool (no cross-tool union, which would erase the arg types). The returned
|
|
21
|
+
* object carries a `register` closure the server calls during setup.
|
|
22
|
+
*/
|
|
23
|
+
function defineTool(spec) {
|
|
24
|
+
return {
|
|
25
|
+
name: spec.name,
|
|
26
|
+
register(server, ctx) {
|
|
27
|
+
server.registerTool(spec.name, {
|
|
28
|
+
title: spec.title,
|
|
29
|
+
description: spec.description,
|
|
30
|
+
inputSchema: spec.inputSchema,
|
|
31
|
+
annotations: spec.annotations,
|
|
32
|
+
},
|
|
33
|
+
// The SDK validates `args` against `inputSchema` before invoking us, so
|
|
34
|
+
// `args` already has the handler's input shape. Typed as the SDK's own
|
|
35
|
+
// `ToolCallback<Shape>` so the registration overload resolves cleanly.
|
|
36
|
+
(async (args) => {
|
|
37
|
+
try {
|
|
38
|
+
return await spec.handler(args, ctx);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return toErrorResult(err);
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Reusable schema fragments
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
const inboxRef = z
|
|
51
|
+
.string()
|
|
52
|
+
.min(1)
|
|
53
|
+
.describe("Inbox id — the canonical OPAQUE inbox_id (`pmbx_…`; treat it as opaque), or the inbox's " +
|
|
54
|
+
"full email address as a within-project alias (agent7@smtp.extrovert.dev).");
|
|
55
|
+
const emailAddress = z.string().email().describe("An email address.");
|
|
56
|
+
/**
|
|
57
|
+
* Optional assertion that the request's project matches the agent key's FIXED
|
|
58
|
+
* bound project — NEVER a selector. A mismatch is rejected server-side. Project
|
|
59
|
+
* binding is read from the key (see whoami); this only lets a caller assert it.
|
|
60
|
+
*/
|
|
61
|
+
const projectAssertion = z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Optional project_id ASSERTION — must match the key's fixed bound project (see whoami). " +
|
|
66
|
+
"This is NOT a project selector; a mismatch is rejected. Omit unless you want the safety check.");
|
|
67
|
+
/** A single metadata value on a create/update patch (number values stay numbers). */
|
|
68
|
+
const metadataValue = z.union([z.string().max(256), z.number(), z.boolean()]);
|
|
69
|
+
/**
|
|
70
|
+
* Inbox metadata on create: string/number/boolean values; nested objects/arrays
|
|
71
|
+
* rejected. A key whose value is `null` is DROPPED on create (mirrors the
|
|
72
|
+
* CreateInboxInput docstring + the SDK InboxMetadataPatch create shape).
|
|
73
|
+
*/
|
|
74
|
+
const createMetadata = z
|
|
75
|
+
.record(metadataValue.nullable())
|
|
76
|
+
.describe("Arbitrary key-value metadata to store on the inbox (string/number/boolean values; ≤256 keys, " +
|
|
77
|
+
"≤256 chars per key/string value; nested objects/arrays rejected; a key with a null value is " +
|
|
78
|
+
"dropped). Echoed back on the response and replayed on idempotent retries.");
|
|
79
|
+
/**
|
|
80
|
+
* Inbox metadata patch on update: merge-null-clear semantics — a value SETS a
|
|
81
|
+
* key, `null` DELETES that key, and a top-level `null` clears ALL metadata.
|
|
82
|
+
*/
|
|
83
|
+
const updateMetadata = z
|
|
84
|
+
.record(metadataValue.nullable())
|
|
85
|
+
.nullable()
|
|
86
|
+
.describe("Patch the inbox's metadata with a shallow merge: an object merges in (a key whose value is null " +
|
|
87
|
+
"DELETES that key); a top-level null clears ALL metadata; omit the field to leave it unchanged. " +
|
|
88
|
+
"Values are string/number/boolean; nested objects/arrays rejected; ≤256 keys, ≤256 chars each.");
|
|
89
|
+
/** The agent's "for the human reviewer" intent (Review Loop, spec §11, D3). */
|
|
90
|
+
const reviewIntent = z
|
|
91
|
+
.object({
|
|
92
|
+
summary: z.string().describe("Free-text intent summary (who/what/why). Required when mode is review."),
|
|
93
|
+
meta: z
|
|
94
|
+
.object({
|
|
95
|
+
goal: z.string().optional(),
|
|
96
|
+
recipient: z.string().optional(),
|
|
97
|
+
prior_touches: z.number().int().optional(),
|
|
98
|
+
urgency: z.string().optional(),
|
|
99
|
+
})
|
|
100
|
+
.optional()
|
|
101
|
+
.describe("Optional structured intent payload."),
|
|
102
|
+
})
|
|
103
|
+
.describe("Intent for the human reviewer. Required when the resolved mode is review.");
|
|
104
|
+
const reviewModeEnum = z
|
|
105
|
+
.enum(["review", "direct"])
|
|
106
|
+
.describe("Review Loop assertion: 'review' routes into the human-review queue; 'direct' requests an immediate send. " +
|
|
107
|
+
"The account/inbox review policy may downgrade 'direct' to 'review'.");
|
|
108
|
+
/** The review-request states (spec §3.1), as a const tuple for zod enums. */
|
|
109
|
+
const REVIEW_STATES = [
|
|
110
|
+
"needs_review",
|
|
111
|
+
"in_review",
|
|
112
|
+
"chatting",
|
|
113
|
+
"stale",
|
|
114
|
+
"approved",
|
|
115
|
+
"sent",
|
|
116
|
+
"auto_sent",
|
|
117
|
+
"rejected",
|
|
118
|
+
"stalled",
|
|
119
|
+
"cancelled",
|
|
120
|
+
"failed",
|
|
121
|
+
];
|
|
122
|
+
/** One outbound attachment: filename + MIME type + base64 of the file bytes. */
|
|
123
|
+
const attachmentInput = z
|
|
124
|
+
.object({
|
|
125
|
+
filename: z.string().min(1).max(255).describe("File name shown to the recipient, e.g. invoice.pdf."),
|
|
126
|
+
content_type: z
|
|
127
|
+
.string()
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(127)
|
|
130
|
+
.describe("MIME type, e.g. application/pdf, image/png, text/csv."),
|
|
131
|
+
content_base64: z.string().min(1).describe("Standard base64 of the raw file bytes."),
|
|
132
|
+
})
|
|
133
|
+
.describe("A file to attach (base64).");
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Render helpers — compact, human-skimmable text alongside structuredContent
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
/** Render a mailbox's credentials as a Himalaya `config.toml` account block. */
|
|
138
|
+
function renderHimalayaConfig(c, accountName) {
|
|
139
|
+
const name = (accountName ?? c.address.split("@")[0] ?? "extrovert").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
140
|
+
const enc = (s) => s === "starttls" ? "start-tls" : "tls";
|
|
141
|
+
const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
142
|
+
return [
|
|
143
|
+
`# Himalaya account for ${c.address} — write to ~/.config/himalaya/config.toml`,
|
|
144
|
+
`[accounts.${name}]`,
|
|
145
|
+
`email = ${q(c.address)}`,
|
|
146
|
+
`default = true`,
|
|
147
|
+
``,
|
|
148
|
+
`backend.type = "imap"`,
|
|
149
|
+
`backend.host = ${q(c.imap.host)}`,
|
|
150
|
+
`backend.port = ${c.imap.port}`,
|
|
151
|
+
`backend.encryption.type = "${enc(c.imap.security)}"`,
|
|
152
|
+
`backend.login = ${q(c.username)}`,
|
|
153
|
+
`backend.auth.type = "password"`,
|
|
154
|
+
`backend.auth.raw = ${q(c.password)}`,
|
|
155
|
+
``,
|
|
156
|
+
`message.send.backend.type = "smtp"`,
|
|
157
|
+
`message.send.backend.host = ${q(c.smtp.host)}`,
|
|
158
|
+
`message.send.backend.port = ${c.smtp.port}`,
|
|
159
|
+
`message.send.backend.encryption.type = "${enc(c.smtp.security)}"`,
|
|
160
|
+
`message.send.backend.login = ${q(c.username)}`,
|
|
161
|
+
`message.send.backend.auth.type = "password"`,
|
|
162
|
+
`message.send.backend.auth.raw = ${q(c.password)}`,
|
|
163
|
+
``,
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
function renderInbox(inbox) {
|
|
167
|
+
const sender = inbox.sender_verified ? "sender verified" : "sender pending";
|
|
168
|
+
const lines = [
|
|
169
|
+
`${inbox.address} [${inbox.status}]`,
|
|
170
|
+
`id: ${inbox.id} · domain: ${inbox.domain} (${inbox.onboarding_mode}) · ${sender}`,
|
|
171
|
+
];
|
|
172
|
+
// Surface the fixed org/project the inbox lives in (RFC D9) when present.
|
|
173
|
+
if (inbox.project_id || inbox.org_id) {
|
|
174
|
+
lines.push(`org: ${inbox.org_id ?? "(none)"} · project: ${inbox.project_id ?? "(none)"}`);
|
|
175
|
+
}
|
|
176
|
+
if (inbox.display_name)
|
|
177
|
+
lines.push(`display name: ${inbox.display_name}`);
|
|
178
|
+
lines.push(`daily send limit: ${inbox.daily_send_limit} recipients / rolling 24h`);
|
|
179
|
+
// The policy governs EVERY send from this inbox, so print it: an agent that reads
|
|
180
|
+
// it here composes an intent up front instead of learning the policy by being
|
|
181
|
+
// refused mid-task.
|
|
182
|
+
if (inbox.effective_review_policy) {
|
|
183
|
+
const note = inbox.effective_review_policy === "allow_direct"
|
|
184
|
+
? "sends go out immediately"
|
|
185
|
+
: "every send needs an `intent`; a send without one is refused (intent_required)";
|
|
186
|
+
lines.push(`review policy: ${inbox.effective_review_policy} — ${note}`);
|
|
187
|
+
}
|
|
188
|
+
if (inbox.webhook_url)
|
|
189
|
+
lines.push(`webhook: ${inbox.webhook_url}`);
|
|
190
|
+
const metaKeys = inbox.metadata ? Object.keys(inbox.metadata) : [];
|
|
191
|
+
if (metaKeys.length) {
|
|
192
|
+
const pairs = metaKeys.map((k) => `${k}=${String(inbox.metadata[k])}`).join(", ");
|
|
193
|
+
lines.push(`metadata: ${pairs}`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
function renderMessageHeader(m) {
|
|
198
|
+
const arrow = m.direction === "inbound" ? "<-" : "->";
|
|
199
|
+
const who = m.direction === "inbound" ? fmtAddr(m.from) : m.to.map(fmtAddr).join(", ");
|
|
200
|
+
const seen = m.direction === "inbound" ? (m.seen ? "" : " · unread") : "";
|
|
201
|
+
return `${arrow} ${who} · ${m.subject} · ${m.date}${seen}\n id: ${m.id} · thread: ${m.thread_id}`;
|
|
202
|
+
}
|
|
203
|
+
function messagePreview(m) {
|
|
204
|
+
return m.text?.trim() || m.html?.trim() || "(message has no text/plain or text/html body)";
|
|
205
|
+
}
|
|
206
|
+
function renderMessageBody(m, format, variant) {
|
|
207
|
+
const text = (variant === "source" ? m.text : m.extracted_text)?.trim() || null;
|
|
208
|
+
const html = (variant === "source" ? m.html : m.extracted_html)?.trim() || null;
|
|
209
|
+
const textName = variant === "source" ? "text/plain MIME part" : "extracted_text";
|
|
210
|
+
const htmlName = variant === "source" ? "text/html MIME part" : "extracted_html";
|
|
211
|
+
if (format === "text")
|
|
212
|
+
return text ?? `No ${textName} is present. No content was synthesized.`;
|
|
213
|
+
if (format === "html")
|
|
214
|
+
return html ?? `No ${htmlName} is present. No content was synthesized.`;
|
|
215
|
+
if (format === "both") {
|
|
216
|
+
return [
|
|
217
|
+
`Text:\n${text ?? `(no ${textName}; not synthesized)`}`,
|
|
218
|
+
`HTML:\n${html ?? `(no ${htmlName}; not synthesized)`}`,
|
|
219
|
+
].join("\n\n");
|
|
220
|
+
}
|
|
221
|
+
if (text)
|
|
222
|
+
return text;
|
|
223
|
+
if (html)
|
|
224
|
+
return html;
|
|
225
|
+
return `No ${variant} text or HTML content is present.`;
|
|
226
|
+
}
|
|
227
|
+
function fmtAddr(a) {
|
|
228
|
+
return a.name ? `${a.name} <${a.email}>` : a.email;
|
|
229
|
+
}
|
|
230
|
+
function renderThread(t) {
|
|
231
|
+
return [
|
|
232
|
+
`${t.subject} (${t.message_count} msg)`,
|
|
233
|
+
` id: ${t.id} · last: ${t.last_message_at}`,
|
|
234
|
+
` with: ${t.participants.join(", ")}`,
|
|
235
|
+
` ${t.snippet}`,
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
function renderSendResult(r) {
|
|
239
|
+
const review = r.review_id ? ` · review: ${r.review_id}` : "";
|
|
240
|
+
return `message_id: ${r.message_id || "(queued)"} · thread: ${r.thread_id}${review}`;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Render the outcome of a BARE send/reply/forward.
|
|
244
|
+
*
|
|
245
|
+
* The endpoint has two outcomes and the account's policy — not the caller — picks
|
|
246
|
+
* between them, so a single "Sent." line would be a lie half the time. A queued
|
|
247
|
+
* result must SAY it was queued and must surface the `rr_…` id: that id is the
|
|
248
|
+
* only handle an agent has to resume after a crash, and it is what every
|
|
249
|
+
* follow-up verb (get_review, submit_revision, the event drain) keys on.
|
|
250
|
+
*/
|
|
251
|
+
function renderSendOutcome(verb, result) {
|
|
252
|
+
if ("kind" in result) {
|
|
253
|
+
return [
|
|
254
|
+
`Queued for human review — NOT sent.`,
|
|
255
|
+
`review: ${result.review.id} · state: ${result.review.state}${result.review.effective_mode ? ` · effective_mode: ${result.review.effective_mode}` : ""}`,
|
|
256
|
+
`Next: monitor it with wait_for_review_event / list_review_events until a \`sent\` or \`send_failed\` event arrives.`,
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
const review = result.review_id ? `\nreview: ${result.review_id}` : "";
|
|
260
|
+
if ("status" in result) {
|
|
261
|
+
return `${verb} (policy allows direct send).\nmessage_id: ${result.message_id}${review}`;
|
|
262
|
+
}
|
|
263
|
+
return `${verb} (policy allows direct send).\n${renderSendResult(result)}`;
|
|
264
|
+
}
|
|
265
|
+
/** Render the discriminated outcome of a Review Loop submit (queued OR sent). */
|
|
266
|
+
function renderSubmitResult(r) {
|
|
267
|
+
if (r.kind === "sent") {
|
|
268
|
+
const review = r.review?.id ? `\nreview: ${r.review.id}` : "";
|
|
269
|
+
return `Sent.\nmessage_id: ${r.message.id}${r.message.thread_id ? ` · thread: ${r.message.thread_id}` : ""}${review}`;
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
`Queued for review — NOT sent.`,
|
|
273
|
+
`review: ${r.review.id} · state: ${r.review.state}${r.review.effective_mode ? ` · effective_mode: ${r.review.effective_mode}` : ""}`,
|
|
274
|
+
`Next: monitor it with wait_for_review_event / list_review_events until a \`sent\` or \`send_failed\` event arrives.`,
|
|
275
|
+
].join("\n");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Render a summary of a review request for tool output.
|
|
279
|
+
*
|
|
280
|
+
* `revision` (and `version`) are printed because submit_revision's own contract is
|
|
281
|
+
* "parent_revision MUST equal the draft's current revision (from get_review)" —
|
|
282
|
+
* without them here the documented CAS is literally unperformable from the
|
|
283
|
+
* rendered text, and a text-only agent has no way to redraft. `closed` /
|
|
284
|
+
* `send_path` / `send_error` are the poll-side "am I done?" answer for an agent
|
|
285
|
+
* that lost its event cursor.
|
|
286
|
+
*/
|
|
287
|
+
function renderReview(r) {
|
|
288
|
+
const subject = r.proposed_subject || "(no subject)";
|
|
289
|
+
const intent = r.intent_summary ? `\n intent: ${r.intent_summary}` : "";
|
|
290
|
+
const cat = r.category_id ? ` · category: ${r.category_id}` : "";
|
|
291
|
+
const version = r.version !== undefined ? ` · version: ${r.version}` : "";
|
|
292
|
+
const lines = [
|
|
293
|
+
`${r.id} [${r.state}] ${r.kind} from ${r.from_address}${cat}`,
|
|
294
|
+
` revision: ${r.revision}${version} (pass revision as parent_revision to submit_revision)`,
|
|
295
|
+
` subject: ${subject}${intent}`,
|
|
296
|
+
];
|
|
297
|
+
if (r.sent_message_id)
|
|
298
|
+
lines.push(` sent message: ${r.sent_message_id}`);
|
|
299
|
+
if (r.send_path)
|
|
300
|
+
lines.push(` send path: ${r.send_path}`);
|
|
301
|
+
if (r.send_error)
|
|
302
|
+
lines.push(` send error: ${r.send_error}`);
|
|
303
|
+
if (r.closed !== undefined) {
|
|
304
|
+
lines.push(r.closed
|
|
305
|
+
? " closed: yes — this review is finished; stop polling it."
|
|
306
|
+
: " closed: no — still open; keep draining review events.");
|
|
307
|
+
}
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|
|
310
|
+
/** Render a one-line summary of a review thread turn. */
|
|
311
|
+
function renderReviewTurn(t) {
|
|
312
|
+
const body = t.body ? `: ${t.body.replace(/\s+/g, " ").slice(0, 120)}` : "";
|
|
313
|
+
return `#${t.seq} ${t.turn_type} (${t.actor_kind})${body}`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Render a summary of a category for the registry browse.
|
|
317
|
+
*
|
|
318
|
+
* `rules_version` / `rule_high_water` are printed because they are the values an
|
|
319
|
+
* agent pins into `submit_revision`'s `rules_version_seen` — the born-stale basis.
|
|
320
|
+
* Omitting them made that argument unfillable from the rendered text, so a
|
|
321
|
+
* redraft could never truthfully claim what it was composed against.
|
|
322
|
+
*/
|
|
323
|
+
function renderCategory(c) {
|
|
324
|
+
const desc = c.description ? `\n ${c.description}` : "";
|
|
325
|
+
const versions = c.rules_version !== undefined || c.rule_high_water !== undefined
|
|
326
|
+
? `\n rules_version: ${c.rules_version} · rule_high_water: ${c.rule_high_water}` +
|
|
327
|
+
` (pass rule_high_water as submit_revision's rules_version_seen)`
|
|
328
|
+
: "";
|
|
329
|
+
return `${c.id} [${c.state}] ${c.name} (${c.scope})${versions}${desc}`;
|
|
330
|
+
}
|
|
331
|
+
/** Render a one-line summary of a writing rule for the ordered get_rules ladder. */
|
|
332
|
+
function renderRule(r) {
|
|
333
|
+
const where = r.scope === "general" ? "house-style" : `category ${r.category_id ?? ""}`;
|
|
334
|
+
const tag = r.scope_agent_id ? " · per-agent" : "";
|
|
335
|
+
const layer = r.rule_layer ? ` · ${r.rule_layer}-layer` : "";
|
|
336
|
+
return `${r.id} (rev ${r.rev}) [${r.kind}/${r.author_kind}${tag}${layer}] ${where}\n ${r.rule_text}`;
|
|
337
|
+
}
|
|
338
|
+
/** Render a one-line summary of a change/undo audit row. */
|
|
339
|
+
function renderRuleAudit(e) {
|
|
340
|
+
const undone = e.undone ? " (undone)" : "";
|
|
341
|
+
return `${e.id} ${e.action} ${e.entity_kind} ${e.entity_id} by ${e.actor_kind}${undone}`;
|
|
342
|
+
}
|
|
343
|
+
function renderWebhook(w) {
|
|
344
|
+
const scope = w.inbox ? `inbox ${w.inbox}` : "all inboxes";
|
|
345
|
+
const lines = [
|
|
346
|
+
`${w.url} [${w.active ? "active" : "inactive"}]`,
|
|
347
|
+
`id: ${w.id} · events: ${w.events.join(", ")} · scope: ${scope}`,
|
|
348
|
+
];
|
|
349
|
+
if (w.agent_id)
|
|
350
|
+
lines.push(`agent: ${w.agent_id}`);
|
|
351
|
+
if (w.secret)
|
|
352
|
+
lines.push(`secret (shown once): ${w.secret}`);
|
|
353
|
+
else
|
|
354
|
+
lines.push(`secret: ${w.secret_prefix}… (set at registration, not retrievable)`);
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
function renderContactListEntry(e) {
|
|
358
|
+
const scope = e.inbox ? `inbox ${e.inbox}` : "all inboxes (account-wide)";
|
|
359
|
+
return `${e.kind.toUpperCase()} ${e.pattern} [${e.direction}]\n id: ${e.id} · scope: ${scope}`;
|
|
360
|
+
}
|
|
361
|
+
/** Render a one-line summary of a recipient suppression (opt-out) row. */
|
|
362
|
+
function renderSuppression(s) {
|
|
363
|
+
const status = s.revoked ? "revoked" : "active";
|
|
364
|
+
const narrow = s.narrow_agent_id || s.narrow_mailbox ? " · narrowed" : "";
|
|
365
|
+
const lines = [
|
|
366
|
+
`${s.recipient} [${status}]`,
|
|
367
|
+
` id: ${s.id} · scope: ${s.scope} · source: ${s.source}${narrow} · since ${s.created_at}`,
|
|
368
|
+
];
|
|
369
|
+
if (s.revoked)
|
|
370
|
+
lines.push(` revoked ${s.revoked_at ?? ""} by ${s.revoked_by ?? "?"}: ${s.revoke_reason ?? ""}`);
|
|
371
|
+
return lines.join("\n");
|
|
372
|
+
}
|
|
373
|
+
function renderDomain(d) {
|
|
374
|
+
const lines = [
|
|
375
|
+
`${d.domain} [${d.verification_status}]`,
|
|
376
|
+
`id: ${d.id} · mode: ${d.mode} · dkim: ${d.dkim_status}${d.shared ? " · shared" : ""}`,
|
|
377
|
+
];
|
|
378
|
+
if (d.provisioning_phase)
|
|
379
|
+
lines.push(`phase: ${d.provisioning_phase}`);
|
|
380
|
+
if (d.provisioning_error)
|
|
381
|
+
lines.push(`error: ${d.provisioning_error}`);
|
|
382
|
+
const recs = [...(d.delegation_ns ?? []), ...(d.records ?? [])];
|
|
383
|
+
if (recs.length) {
|
|
384
|
+
lines.push("DNS records to set:");
|
|
385
|
+
for (const r of recs) {
|
|
386
|
+
const prio = r.priority != null ? ` priority ${r.priority}` : "";
|
|
387
|
+
lines.push(` ${r.name} ${r.type} ${r.value}${prio} (ttl ${r.ttl})`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (d.instruction)
|
|
391
|
+
lines.push(d.instruction);
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|
|
394
|
+
function renderJob(j) {
|
|
395
|
+
const lines = [`${j.id} [${j.status}]`, `type: ${j.type} · created ${j.created_at} · updated ${j.updated_at}`];
|
|
396
|
+
if (j.finished_at)
|
|
397
|
+
lines.push(`finished: ${j.finished_at}`);
|
|
398
|
+
const terminal = j.status === "succeeded" || j.status === "failed" || j.status === "cancelled";
|
|
399
|
+
lines.push(terminal ? "terminal — no further polling needed." : "not terminal yet — keep polling.");
|
|
400
|
+
return lines.join("\n");
|
|
401
|
+
}
|
|
402
|
+
function ok(text, structured) {
|
|
403
|
+
return { content: [{ type: "text", text }], structuredContent: structured };
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Tool definitions
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
const redeemEnrollment = defineTool({
|
|
409
|
+
name: "redeem_enrollment",
|
|
410
|
+
title: "Redeem enrollment key",
|
|
411
|
+
description: "Exchange a single-use enrollment token (pk_enroll_…) for a SCOPED agent key (pk_agent_…) bound to this agent. " +
|
|
412
|
+
"Call this first when the host was started without EXTROVERT_API_KEY. The returned agent_key is shown once — store it " +
|
|
413
|
+
"securely; it carries only the granted scopes (e.g. mailbox:create) and can be revoked independently. Pass a stable agent_handle " +
|
|
414
|
+
"to make redemption idempotent (re-redeeming returns the same agent).",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
enrollment_token: z
|
|
417
|
+
.string()
|
|
418
|
+
.min(8)
|
|
419
|
+
.describe("The enrollment token to redeem, e.g. pk_enroll_42_aZ9…."),
|
|
420
|
+
agent_handle: z
|
|
421
|
+
.string()
|
|
422
|
+
.min(1)
|
|
423
|
+
.max(128)
|
|
424
|
+
.optional()
|
|
425
|
+
.describe("Optional stable handle for idempotent enrollment (à la a client id)."),
|
|
426
|
+
client_id: z
|
|
427
|
+
.string()
|
|
428
|
+
.min(1)
|
|
429
|
+
.max(128)
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("Optional idempotency key. Re-redeeming with the same client_id replays the original enrollment response."),
|
|
432
|
+
},
|
|
433
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
434
|
+
handler: async (args, { client }) => {
|
|
435
|
+
const result = await client.redeemEnrollment({
|
|
436
|
+
enrollment_token: args.enrollment_token,
|
|
437
|
+
agent_handle: args.agent_handle,
|
|
438
|
+
client_id: args.client_id,
|
|
439
|
+
});
|
|
440
|
+
const text = [
|
|
441
|
+
`Enrollment redeemed. Agent ${result.agent_id} is ready.`,
|
|
442
|
+
`agent_key (shown once): ${result.agent_key}`,
|
|
443
|
+
// Surface the FIXED org/project the minted key is bound to (enroll now resolves
|
|
444
|
+
// and returns them), so the agent sees its scope at mint time without a second
|
|
445
|
+
// whoami call — consistent with whoami's text.
|
|
446
|
+
`org: ${result.org_id || "(none)"} · project: ${result.project_id || "(none)"} (fixed — bound to this key)`,
|
|
447
|
+
`scopes: ${result.scopes.join(", ") || "(none)"}`,
|
|
448
|
+
"This MCP session will use the returned key. Store the complete returned agent_key as EXTROVERT_API_KEY for future sessions; it is not retrievable again.",
|
|
449
|
+
].join("\n");
|
|
450
|
+
return ok(text, result);
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
const signUp = defineTool({
|
|
454
|
+
name: "sign_up",
|
|
455
|
+
title: "Sign up for a free account",
|
|
456
|
+
description: "Grab a free Extrovert account in one call (no enrollment token needed). Provisions a tenant and a first inbox, then " +
|
|
457
|
+
"emails a one-time verification code to your human_email. Returns a LIMITED (read-only) agent key that this " +
|
|
458
|
+
"MCP session keeps for verify_signup; store it as EXTROVERT_API_KEY for future sessions. Re-calling with the same " +
|
|
459
|
+
"human_email rotates the key and resends the code.",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
human_email: emailAddress.describe("Your email — receives the one-time verification code."),
|
|
462
|
+
username: z
|
|
463
|
+
.string()
|
|
464
|
+
.regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/i, "local-part of an email address")
|
|
465
|
+
.max(64)
|
|
466
|
+
.optional()
|
|
467
|
+
.describe("Desired local-part for the first inbox (optional)."),
|
|
468
|
+
},
|
|
469
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
470
|
+
handler: async (args, { client }) => {
|
|
471
|
+
const res = await client.signUp({ human_email: args.human_email, username: args.username });
|
|
472
|
+
const text = [
|
|
473
|
+
`Account created. A verification code was sent to ${res.otp_sent_to}.`,
|
|
474
|
+
`inbox: ${res.address}`,
|
|
475
|
+
`agent_key (limited, shown once): ${res.agent_key}`,
|
|
476
|
+
`scopes: ${res.scopes.join(", ")}`,
|
|
477
|
+
`This MCP session will use the limited key. Call verify_signup with the code to unlock full scopes; store the key as EXTROVERT_API_KEY for future sessions if needed.`,
|
|
478
|
+
].join("\n");
|
|
479
|
+
return ok(text, res);
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
const verifySignup = defineTool({
|
|
483
|
+
name: "verify_signup",
|
|
484
|
+
title: "Verify signup code",
|
|
485
|
+
description: "Confirm the one-time code emailed by sign_up. On success you receive a NEW full-scope agent key (create/read/send) — " +
|
|
486
|
+
"this MCP session switches to it automatically. Store it as EXTROVERT_API_KEY for future sessions.",
|
|
487
|
+
inputSchema: {
|
|
488
|
+
otp: z.string().min(4).max(12).describe("The verification code from your signup email."),
|
|
489
|
+
},
|
|
490
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
491
|
+
handler: async (args, { client }) => {
|
|
492
|
+
const res = await client.verify({ otp: args.otp });
|
|
493
|
+
const text = [
|
|
494
|
+
`Verified. ${res.message}`,
|
|
495
|
+
`agent_key (full, shown once): ${res.agent_key}`,
|
|
496
|
+
`scopes: ${res.scopes.join(", ")}`,
|
|
497
|
+
].join("\n");
|
|
498
|
+
return ok(text, res);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
const whoami = defineTool({
|
|
502
|
+
name: "whoami",
|
|
503
|
+
title: "Who am I",
|
|
504
|
+
description: "Introspect the principal behind the current agent key: the tenant (customer), the FIXED org and project the key " +
|
|
505
|
+
"is bound to, the agent, key id, and granted scopes. The org/project are stamped on the key when it is issued and " +
|
|
506
|
+
"cannot be changed at runtime — there is no project selector; this is the canonical place to read which project " +
|
|
507
|
+
"this key acts in. Use it to check what the active key can do before attempting a scoped action.",
|
|
508
|
+
inputSchema: {},
|
|
509
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
510
|
+
handler: async (_args, { client }) => {
|
|
511
|
+
const me = await client.whoami();
|
|
512
|
+
const text = [
|
|
513
|
+
`agent: ${me.agent_id} · customer: ${me.customer_id} · key: ${me.key_id}`,
|
|
514
|
+
`org: ${me.org_id || "(none)"} · project: ${me.project_id || "(none)"} (fixed — bound to this key)`,
|
|
515
|
+
`scopes: ${me.scopes.join(", ") || "(none)"}`,
|
|
516
|
+
].join("\n");
|
|
517
|
+
return ok(text, me);
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const createInbox = defineTool({
|
|
521
|
+
name: "create_inbox",
|
|
522
|
+
title: "Create inbox",
|
|
523
|
+
description: "Provision a real, persistent inbox for this agent in one call. Omit username/domain to mint an instant address on a " +
|
|
524
|
+
"pre-warmed, verified shared smtp.extrovert.dev subdomain (default, zero-config). Sender registration is always included " +
|
|
525
|
+
"— the inbox can send and receive immediately. Attach arbitrary metadata (string/number/boolean values) to tag the " +
|
|
526
|
+
"inbox; it is echoed back and replayed on idempotent retries. The inbox is created in the key's fixed project. " +
|
|
527
|
+
"Returns the address and inbox id.",
|
|
528
|
+
inputSchema: {
|
|
529
|
+
username: z
|
|
530
|
+
.string()
|
|
531
|
+
.regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/i, "local-part of an email address")
|
|
532
|
+
.max(64)
|
|
533
|
+
.optional()
|
|
534
|
+
.describe("Desired local-part (before the @). Omit for an auto-generated handle."),
|
|
535
|
+
domain: z
|
|
536
|
+
.string()
|
|
537
|
+
.optional()
|
|
538
|
+
.describe("Domain to mint on (must be an org domain). Omit for the default shared subdomain."),
|
|
539
|
+
display_name: z.string().max(128).optional().describe("Display name on outbound mail."),
|
|
540
|
+
inbound_webhook_url: z
|
|
541
|
+
.string()
|
|
542
|
+
.url()
|
|
543
|
+
.optional()
|
|
544
|
+
.describe("HTTPS URL to receive HMAC-signed message.received webhooks."),
|
|
545
|
+
metadata: createMetadata.optional(),
|
|
546
|
+
project_id: projectAssertion,
|
|
547
|
+
client_id: z
|
|
548
|
+
.string()
|
|
549
|
+
.min(1)
|
|
550
|
+
.max(128)
|
|
551
|
+
.optional()
|
|
552
|
+
.describe("Optional idempotency key. Re-calling with the same client_id returns the existing inbox instead of creating a duplicate."),
|
|
553
|
+
},
|
|
554
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
555
|
+
handler: async (args, { client }) => {
|
|
556
|
+
const inbox = await client.createInbox({
|
|
557
|
+
username: args.username,
|
|
558
|
+
domain: args.domain,
|
|
559
|
+
display_name: args.display_name,
|
|
560
|
+
inbound_webhook_url: args.inbound_webhook_url,
|
|
561
|
+
metadata: args.metadata,
|
|
562
|
+
project_id: args.project_id,
|
|
563
|
+
client_id: args.client_id,
|
|
564
|
+
});
|
|
565
|
+
return ok(`Inbox live.\n${renderInbox(inbox)}`, inbox);
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
const listInboxes = defineTool({
|
|
569
|
+
name: "list_inboxes",
|
|
570
|
+
title: "List inboxes",
|
|
571
|
+
description: "List the inboxes this agent owns, newest first. Scope is in the KEY: a project " +
|
|
572
|
+
"(default) key lists its project's inboxes with no extra args. An ORG-tier key " +
|
|
573
|
+
"must pick a breadth — pass `project` (a concrete project id) or `wildcard:true` " +
|
|
574
|
+
"(the whole org subtree); a bare org-key list is rejected (breadth_required).",
|
|
575
|
+
inputSchema: {
|
|
576
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max inboxes to return."),
|
|
577
|
+
project: z
|
|
578
|
+
.string()
|
|
579
|
+
.min(1)
|
|
580
|
+
.optional()
|
|
581
|
+
.describe("Narrow to a concrete project id (org-tier keys, or a project key for its own project). " +
|
|
582
|
+
"Omit for a project/inbox key — its project is implicit."),
|
|
583
|
+
wildcard: z
|
|
584
|
+
.boolean()
|
|
585
|
+
.optional()
|
|
586
|
+
.describe("Org-tier keys only: list inboxes across the WHOLE org subtree (the `/v1/projects/-/inboxes` " +
|
|
587
|
+
"form). Rows carry org_id + project_id. A non-org key using this is rejected (forbidden_scope)."),
|
|
588
|
+
},
|
|
589
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
590
|
+
handler: async (args, { client }) => {
|
|
591
|
+
const page = await client.listInboxes({
|
|
592
|
+
limit: args.limit,
|
|
593
|
+
project: args.project,
|
|
594
|
+
wildcard: args.wildcard,
|
|
595
|
+
});
|
|
596
|
+
const text = page.items.length
|
|
597
|
+
? page.items.map(renderInbox).join("\n\n")
|
|
598
|
+
: "No inboxes yet. Create one with create_inbox.";
|
|
599
|
+
return ok(`${page.items.length} inbox(es).\n\n${text}`, {
|
|
600
|
+
items: page.items,
|
|
601
|
+
total: page.total,
|
|
602
|
+
next_cursor: page.next_cursor,
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const getInbox = defineTool({
|
|
607
|
+
name: "get_inbox",
|
|
608
|
+
title: "Get inbox",
|
|
609
|
+
description: "Fetch one inbox by id or address (includes its metadata).",
|
|
610
|
+
inputSchema: { inbox: inboxRef },
|
|
611
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
612
|
+
handler: async (args, { client }) => {
|
|
613
|
+
const inbox = await client.getInbox(args.inbox);
|
|
614
|
+
return ok(renderInbox(inbox), inbox);
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
const updateInbox = defineTool({
|
|
618
|
+
name: "update_inbox",
|
|
619
|
+
title: "Update inbox",
|
|
620
|
+
description: "Update an inbox's settings in place — no delete+recreate. Set display_name to change the 'From' name shown on " +
|
|
621
|
+
"outbound mail (propagated to the authenticated sender), or inbound_webhook_url to change/clear the inbound webhook. " +
|
|
622
|
+
"Set daily_send_limit to an integer from 1 through 10,000 to change the effective rolling-24-hour recipient cap; " +
|
|
623
|
+
"this field requires the opt-in mailbox:quota scope. " +
|
|
624
|
+
"Patch metadata with a shallow merge: pass an object to merge in (a key whose value is null DELETES that key), pass " +
|
|
625
|
+
"a top-level null to clear ALL metadata, or omit metadata to leave it unchanged. Returns the updated inbox.",
|
|
626
|
+
inputSchema: {
|
|
627
|
+
inbox: inboxRef,
|
|
628
|
+
display_name: z
|
|
629
|
+
.string()
|
|
630
|
+
.max(128)
|
|
631
|
+
.optional()
|
|
632
|
+
.describe("New sender display / 'From' name. Empty string falls back to the address local-part."),
|
|
633
|
+
inbound_webhook_url: z
|
|
634
|
+
.string()
|
|
635
|
+
.optional()
|
|
636
|
+
.describe("Replace the inbound webhook target (empty string clears it). HTTPS URL."),
|
|
637
|
+
daily_send_limit: z
|
|
638
|
+
.number()
|
|
639
|
+
.int()
|
|
640
|
+
.min(1)
|
|
641
|
+
.max(10_000)
|
|
642
|
+
.optional()
|
|
643
|
+
.describe("Effective recipient cap per rolling 24 hours (1–10,000). Requires mailbox:quota."),
|
|
644
|
+
metadata: updateMetadata.optional(),
|
|
645
|
+
project_id: projectAssertion,
|
|
646
|
+
},
|
|
647
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
648
|
+
handler: async (args, { client }) => {
|
|
649
|
+
const inbox = await client.updateInbox(args.inbox, {
|
|
650
|
+
display_name: args.display_name,
|
|
651
|
+
inbound_webhook_url: args.inbound_webhook_url,
|
|
652
|
+
daily_send_limit: args.daily_send_limit,
|
|
653
|
+
metadata: args.metadata,
|
|
654
|
+
project_id: args.project_id,
|
|
655
|
+
});
|
|
656
|
+
return ok(`Inbox updated.\n${renderInbox(inbox)}`, inbox);
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
const exportEmailConfig = defineTool({
|
|
660
|
+
name: "export_email_config",
|
|
661
|
+
title: "Export email client config",
|
|
662
|
+
description: "Export an inbox's IMAP/SMTP server settings + login so you can configure a real mail client (e.g. Himalaya). " +
|
|
663
|
+
"Direct SMTP is an explicit unreviewed delivery path: it bypasses Extrovert approval/review, suppression and " +
|
|
664
|
+
"contact-list enforcement, List-Unsubscribe injection, and Extrovert billing/accounting. API/MCP sends keep those " +
|
|
665
|
+
"controls. Returns a ready-to-use config; use format=json for raw connection fields.",
|
|
666
|
+
inputSchema: {
|
|
667
|
+
inbox: inboxRef,
|
|
668
|
+
format: z
|
|
669
|
+
.enum(["himalaya", "json"])
|
|
670
|
+
.default("himalaya")
|
|
671
|
+
.describe("Output format: a Himalaya config.toml account block, or raw JSON connection fields."),
|
|
672
|
+
account_name: z
|
|
673
|
+
.string()
|
|
674
|
+
.max(64)
|
|
675
|
+
.optional()
|
|
676
|
+
.describe("Himalaya account name (defaults to the address local-part)."),
|
|
677
|
+
},
|
|
678
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
679
|
+
handler: async (args, { client }) => {
|
|
680
|
+
const creds = await client.getCredentials(args.inbox);
|
|
681
|
+
const warning = "DIRECT SMTP BYPASS: SMTP sends do not pass through Extrovert review, suppression/contact-list checks, " +
|
|
682
|
+
"List-Unsubscribe injection, or Extrovert billing/accounting. Use MCP/API send tools when those controls matter.";
|
|
683
|
+
if (args.format === "json") {
|
|
684
|
+
const warnedCredentials = {
|
|
685
|
+
...creds,
|
|
686
|
+
warning,
|
|
687
|
+
};
|
|
688
|
+
return ok(JSON.stringify(warnedCredentials, null, 2), warnedCredentials);
|
|
689
|
+
}
|
|
690
|
+
const toml = renderHimalayaConfig(creds, args.account_name);
|
|
691
|
+
const warnedToml = `# WARNING: ${warning}\n${toml}`;
|
|
692
|
+
return ok(warnedToml, {
|
|
693
|
+
format: "himalaya",
|
|
694
|
+
config: warnedToml,
|
|
695
|
+
credentials: creds,
|
|
696
|
+
warning,
|
|
697
|
+
});
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
const deleteInbox = defineTool({
|
|
701
|
+
name: "delete_inbox",
|
|
702
|
+
title: "Delete inbox",
|
|
703
|
+
description: "Permanently delete an inbox and all of its messages, and tear down its sender identity. Requires mailbox:delete, " +
|
|
704
|
+
"or mailbox:create for the owning agent as the lifecycle-cleanup fallback. " +
|
|
705
|
+
"This cannot be undone or recovered.",
|
|
706
|
+
inputSchema: { inbox: inboxRef },
|
|
707
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
708
|
+
handler: async (args, { client }) => {
|
|
709
|
+
const result = await client.deleteInbox(args.inbox);
|
|
710
|
+
return ok(`Deleted inbox ${result.id}.`, result);
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
const sendEmail = defineTool({
|
|
714
|
+
name: "send_email",
|
|
715
|
+
title: "Send email",
|
|
716
|
+
description: "Compose a new email from one of this agent's inboxes and submit it for HUMAN REVIEW — the default path. " +
|
|
717
|
+
"Starts a new thread. Use reply_email to respond within an existing thread.\n\n" +
|
|
718
|
+
"ALWAYS pass `intent`. The account's review policy governs EVERY send, and the default policy is " +
|
|
719
|
+
"`require_review`: a send with no `intent` is REFUSED with 422 intent_required, and nothing is sent OR queued. " +
|
|
720
|
+
"With an intent you get 202 queued_for_review plus a review id (rr_…) — the message has NOT gone out yet. Then " +
|
|
721
|
+
"monitor that review with wait_for_review_event / list_review_events until a `sent` or `send_failed` event " +
|
|
722
|
+
"arrives. After `send_failed`, close the failed row with cancel_review and ack the following `cancelled` event. " +
|
|
723
|
+
"Read `effective_review_policy` from get_inbox once at the start to know which path you are on; only an " +
|
|
724
|
+
"account explicitly set to `allow_direct` delivers immediately.\n\n" +
|
|
725
|
+
"`mode`/`category_id` refine the routing but never bypass it: the policy resolves the mode, so `mode:\"direct\"` " +
|
|
726
|
+
"under require_review is still queued. `intent.summary` is the first thing the human reviewer reads.\n\n" +
|
|
727
|
+
"Opt-outs and contact lists are enforced at SUBMIT, before the review is created: if ANY recipient is blocked or " +
|
|
728
|
+
"has unsubscribed, the whole request is rejected (recipient_blocked 403 / recipient_suppressed 422) and no review " +
|
|
729
|
+
"is queued for a human to waste time on. The error names the addresses to drop. Use check_suppression first to " +
|
|
730
|
+
"avoid the round-trip.",
|
|
731
|
+
inputSchema: {
|
|
732
|
+
inbox: inboxRef,
|
|
733
|
+
to: z.array(emailAddress).min(1).describe("One or more recipient addresses."),
|
|
734
|
+
subject: z.string().max(255).describe("Subject line."),
|
|
735
|
+
text: z.string().describe("Plain-text body."),
|
|
736
|
+
html: z.string().optional().describe("Optional HTML body."),
|
|
737
|
+
cc: z.array(emailAddress).optional().describe("Optional Cc recipients."),
|
|
738
|
+
bcc: z.array(emailAddress).optional().describe("Optional Bcc recipients."),
|
|
739
|
+
reply_to: emailAddress.optional().describe("Override the Reply-To header."),
|
|
740
|
+
headers: z
|
|
741
|
+
.record(z.string(), z.string())
|
|
742
|
+
.optional()
|
|
743
|
+
.describe("Optional custom headers (e.g. List-Unsubscribe); reserved/unsafe names are dropped."),
|
|
744
|
+
attachments: z
|
|
745
|
+
.array(attachmentInput)
|
|
746
|
+
.max(20)
|
|
747
|
+
.optional()
|
|
748
|
+
.describe("Optional files to attach (base64); sent as a multipart/mixed message."),
|
|
749
|
+
mode: reviewModeEnum.optional(),
|
|
750
|
+
intent: reviewIntent.optional(),
|
|
751
|
+
category_id: z
|
|
752
|
+
.string()
|
|
753
|
+
.optional()
|
|
754
|
+
.describe("Opaque category id (cat_…) matched from the registry. Never a name."),
|
|
755
|
+
category_confidence: z
|
|
756
|
+
.number()
|
|
757
|
+
.min(0)
|
|
758
|
+
.max(1)
|
|
759
|
+
.optional()
|
|
760
|
+
.describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate ONLY; " +
|
|
761
|
+
"the server never scores. Below the threshold (or omitted when one is set) the would-be auto-send " +
|
|
762
|
+
"routes to needs_review (gate_outcome held:low_confidence)."),
|
|
763
|
+
client_id: z
|
|
764
|
+
.string()
|
|
765
|
+
.min(1)
|
|
766
|
+
.max(128)
|
|
767
|
+
.optional()
|
|
768
|
+
.describe("Stable Idempotency-Key for this exact send intent. Reuse it after a transport timeout."),
|
|
769
|
+
},
|
|
770
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
771
|
+
handler: async (args, { client }) => {
|
|
772
|
+
// Review Loop overload: any of mode/intent/category_id opts into the richer
|
|
773
|
+
// discriminated response. It does NOT decide whether a human sees the message —
|
|
774
|
+
// the policy does that either way; this only shapes what comes back.
|
|
775
|
+
if (args.mode !== undefined || args.intent !== undefined || args.category_id !== undefined) {
|
|
776
|
+
const result = await client.submitForReview({
|
|
777
|
+
inbox: args.inbox,
|
|
778
|
+
to: args.to,
|
|
779
|
+
subject: args.subject,
|
|
780
|
+
text: args.text,
|
|
781
|
+
html: args.html,
|
|
782
|
+
cc: args.cc,
|
|
783
|
+
bcc: args.bcc,
|
|
784
|
+
reply_to: args.reply_to,
|
|
785
|
+
headers: args.headers,
|
|
786
|
+
// Attachments survive submit -> review row -> approval dispatch, so the
|
|
787
|
+
// human reviews the message WITH its files. Dropping them here would have
|
|
788
|
+
// shipped a message the reviewer approved with an attachment and the
|
|
789
|
+
// recipient received without one.
|
|
790
|
+
attachments: args.attachments,
|
|
791
|
+
mode: args.mode,
|
|
792
|
+
intent: args.intent,
|
|
793
|
+
category_id: args.category_id,
|
|
794
|
+
category_confidence: args.category_confidence,
|
|
795
|
+
client_id: args.client_id,
|
|
796
|
+
});
|
|
797
|
+
return ok(renderSubmitResult(result), result);
|
|
798
|
+
}
|
|
799
|
+
const result = await client.sendEmail({
|
|
800
|
+
inbox: args.inbox,
|
|
801
|
+
to: args.to,
|
|
802
|
+
subject: args.subject,
|
|
803
|
+
text: args.text,
|
|
804
|
+
html: args.html,
|
|
805
|
+
cc: args.cc,
|
|
806
|
+
bcc: args.bcc,
|
|
807
|
+
reply_to: args.reply_to,
|
|
808
|
+
headers: args.headers,
|
|
809
|
+
attachments: args.attachments,
|
|
810
|
+
client_id: args.client_id,
|
|
811
|
+
});
|
|
812
|
+
return ok(renderSendOutcome("Sent", result), result);
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const replyEmail = defineTool({
|
|
816
|
+
name: "reply_email",
|
|
817
|
+
title: "Reply to thread",
|
|
818
|
+
description: "Compose a reply within an existing thread and submit it for HUMAN REVIEW — the default path, exactly like " +
|
|
819
|
+
"send_email. Select the parent with thread_id (the latest message in that thread) OR message_id (that specific " +
|
|
820
|
+
"message). Recipients, subject, and In-Reply-To/References are derived server-side — you do NOT pass `to`.\n\n" +
|
|
821
|
+
"ALWAYS pass `intent`. Under the default `require_review` policy a reply with no intent is REFUSED with 422 " +
|
|
822
|
+
"intent_required (nothing sent, nothing queued); with one it returns 202 queued_for_review and a review id " +
|
|
823
|
+
"(rr_…), and the reply has NOT gone out until a `sent` review event arrives. The envelope is resolved at submit, " +
|
|
824
|
+
"so the human reviews a message with its real subject and recipients.\n\n" +
|
|
825
|
+
"Opt-outs: a reply to a suppressed recipient is rejected with recipient_suppressed (HTTP 422) at SUBMIT, before a " +
|
|
826
|
+
"review is queued. Replies get ONE narrow exception: a suppressed recipient is allowed when this reply answers an " +
|
|
827
|
+
"inbound message FROM them that arrived AFTER their opt-out (a recipient-re-initiated exchange). Nothing else " +
|
|
828
|
+
"qualifies — send_email and forward_email get no exception at all.",
|
|
829
|
+
inputSchema: {
|
|
830
|
+
inbox: inboxRef,
|
|
831
|
+
thread_id: z.string().min(1).optional().describe("Thread to reply within (thr_…). One of thread_id / message_id."),
|
|
832
|
+
message_id: z.string().min(1).optional().describe("Specific message to reply to (msg_…). One of thread_id / message_id."),
|
|
833
|
+
text: z.string().optional().describe("Plain-text reply body."),
|
|
834
|
+
html: z.string().optional().describe("Optional HTML reply body."),
|
|
835
|
+
cc: z.array(emailAddress).optional().describe("Optional Cc recipients."),
|
|
836
|
+
bcc: z.array(emailAddress).optional().describe("Optional Bcc recipients."),
|
|
837
|
+
reply_to: emailAddress.optional().describe("Override the Reply-To header."),
|
|
838
|
+
headers: z
|
|
839
|
+
.record(z.string(), z.string())
|
|
840
|
+
.optional()
|
|
841
|
+
.describe("Optional custom headers (e.g. List-Unsubscribe); reserved/unsafe names are dropped."),
|
|
842
|
+
reply_all: z.boolean().default(false).describe("Reply to all thread recipients, not just the original sender."),
|
|
843
|
+
attachments: z
|
|
844
|
+
.array(attachmentInput)
|
|
845
|
+
.max(20)
|
|
846
|
+
.optional()
|
|
847
|
+
.describe("Optional files to attach (base64); sent as a multipart/mixed message."),
|
|
848
|
+
mode: reviewModeEnum.optional(),
|
|
849
|
+
intent: reviewIntent.optional(),
|
|
850
|
+
category_id: z
|
|
851
|
+
.string()
|
|
852
|
+
.optional()
|
|
853
|
+
.describe("Opaque category id (cat_…) matched from the registry. Never a name."),
|
|
854
|
+
category_confidence: z
|
|
855
|
+
.number()
|
|
856
|
+
.min(0)
|
|
857
|
+
.max(1)
|
|
858
|
+
.optional()
|
|
859
|
+
.describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate only."),
|
|
860
|
+
client_id: z
|
|
861
|
+
.string()
|
|
862
|
+
.min(1)
|
|
863
|
+
.max(128)
|
|
864
|
+
.optional()
|
|
865
|
+
.describe("Stable Idempotency-Key for this exact reply intent. Reuse it after a transport timeout."),
|
|
866
|
+
},
|
|
867
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
868
|
+
handler: async (args, { client }) => {
|
|
869
|
+
if (!args.thread_id && !args.message_id) {
|
|
870
|
+
throw new ExtrovertApiError("Provide thread_id or message_id to reply.", 400, "invalid_argument");
|
|
871
|
+
}
|
|
872
|
+
// Review Loop overload: any of mode/intent/category_id opts into review.
|
|
873
|
+
if (args.mode !== undefined || args.intent !== undefined || args.category_id !== undefined) {
|
|
874
|
+
const result = await client.submitReplyForReview({
|
|
875
|
+
inbox: args.inbox,
|
|
876
|
+
thread_id: args.thread_id,
|
|
877
|
+
message_id: args.message_id,
|
|
878
|
+
text: args.text ?? "",
|
|
879
|
+
html: args.html,
|
|
880
|
+
cc: args.cc,
|
|
881
|
+
bcc: args.bcc,
|
|
882
|
+
reply_to: args.reply_to,
|
|
883
|
+
headers: args.headers,
|
|
884
|
+
reply_all: args.reply_all,
|
|
885
|
+
attachments: args.attachments,
|
|
886
|
+
mode: args.mode,
|
|
887
|
+
intent: args.intent,
|
|
888
|
+
category_id: args.category_id,
|
|
889
|
+
category_confidence: args.category_confidence,
|
|
890
|
+
client_id: args.client_id,
|
|
891
|
+
});
|
|
892
|
+
return ok(renderSubmitResult(result), result);
|
|
893
|
+
}
|
|
894
|
+
const res = await client.replyEmail({
|
|
895
|
+
inbox: args.inbox,
|
|
896
|
+
thread_id: args.thread_id,
|
|
897
|
+
message_id: args.message_id,
|
|
898
|
+
text: args.text,
|
|
899
|
+
html: args.html,
|
|
900
|
+
cc: args.cc,
|
|
901
|
+
bcc: args.bcc,
|
|
902
|
+
reply_to: args.reply_to,
|
|
903
|
+
headers: args.headers,
|
|
904
|
+
reply_all: args.reply_all,
|
|
905
|
+
attachments: args.attachments,
|
|
906
|
+
client_id: args.client_id,
|
|
907
|
+
});
|
|
908
|
+
return ok(renderSendOutcome("Replied", res), res);
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
const forwardEmail = defineTool({
|
|
912
|
+
name: "forward_email",
|
|
913
|
+
title: "Forward a message",
|
|
914
|
+
description: "Forward an existing message (by its opaque id) to new recipients and submit it for HUMAN REVIEW — the default " +
|
|
915
|
+
"path, exactly like send_email and reply_email. Optionally prepend a plain-text note.\n\n" +
|
|
916
|
+
"ALWAYS pass `intent`. A forward is an outbound message to arbitrary NEW recipients that quotes an entire " +
|
|
917
|
+
"received thread, so the review policy binds it just as hard as a send — otherwise it would be the way around " +
|
|
918
|
+
"review, and a worse one, because it exfiltrates a conversation. Under the default `require_review` policy a " +
|
|
919
|
+
"forward with no intent is REFUSED with 422 intent_required (nothing sent, nothing queued); with one it returns " +
|
|
920
|
+
"202 queued_for_review and a review id (rr_…). The subject and quoted body are materialized at SUBMIT, so the " +
|
|
921
|
+
"human reviews the exact bytes that go out and an approved forward delivers the reviewer's edit.\n\n" +
|
|
922
|
+
"Opt-outs: a forward to a suppressed recipient is ALWAYS rejected (recipient_suppressed 422). Forward gets NO " +
|
|
923
|
+
"solicited-response exception — that exception exists only for a reply answering an inbound message from the " +
|
|
924
|
+
"person who opted out.\n\n" +
|
|
925
|
+
"A forward is deliberately NOT threaded to its parent (no In-Reply-To): the new recipients were never part of " +
|
|
926
|
+
"that conversation.",
|
|
927
|
+
inputSchema: {
|
|
928
|
+
inbox: inboxRef,
|
|
929
|
+
message_id: z.string().min(1).describe("Opaque id of the message to forward (msg_…)."),
|
|
930
|
+
to: z.array(emailAddress).min(1).describe("One or more recipient addresses."),
|
|
931
|
+
cc: z.array(emailAddress).optional().describe("Optional Cc recipients."),
|
|
932
|
+
bcc: z.array(emailAddress).optional().describe("Optional Bcc recipients."),
|
|
933
|
+
text: z.string().optional().describe("Optional note to prepend (plain text)."),
|
|
934
|
+
html: z
|
|
935
|
+
.string()
|
|
936
|
+
.optional()
|
|
937
|
+
.describe("Accepted for wire compatibility but ignored. Forwards use one plain-text body containing the note and quote."),
|
|
938
|
+
mode: reviewModeEnum.optional(),
|
|
939
|
+
intent: reviewIntent.optional(),
|
|
940
|
+
category_id: z
|
|
941
|
+
.string()
|
|
942
|
+
.optional()
|
|
943
|
+
.describe("Opaque category id (cat_…) matched from the registry. Never a name."),
|
|
944
|
+
category_confidence: z
|
|
945
|
+
.number()
|
|
946
|
+
.min(0)
|
|
947
|
+
.max(1)
|
|
948
|
+
.optional()
|
|
949
|
+
.describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate only."),
|
|
950
|
+
client_id: z
|
|
951
|
+
.string()
|
|
952
|
+
.min(1)
|
|
953
|
+
.max(128)
|
|
954
|
+
.optional()
|
|
955
|
+
.describe("Stable Idempotency-Key for this exact forward intent. Reuse it after a transport timeout."),
|
|
956
|
+
},
|
|
957
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
958
|
+
handler: async (args, { client }) => {
|
|
959
|
+
// Same opt-in predicate as send/reply: mode/intent/category_id select the
|
|
960
|
+
// richer discriminated response; the policy governs the routing regardless.
|
|
961
|
+
if (args.mode !== undefined || args.intent !== undefined || args.category_id !== undefined) {
|
|
962
|
+
const result = await client.submitForwardForReview({
|
|
963
|
+
inbox: args.inbox,
|
|
964
|
+
message_id: args.message_id,
|
|
965
|
+
to: args.to,
|
|
966
|
+
cc: args.cc,
|
|
967
|
+
bcc: args.bcc,
|
|
968
|
+
text: args.text,
|
|
969
|
+
html: args.html,
|
|
970
|
+
mode: args.mode,
|
|
971
|
+
intent: args.intent,
|
|
972
|
+
category_id: args.category_id,
|
|
973
|
+
category_confidence: args.category_confidence,
|
|
974
|
+
client_id: args.client_id,
|
|
975
|
+
});
|
|
976
|
+
return ok(renderSubmitResult(result), result);
|
|
977
|
+
}
|
|
978
|
+
const res = await client.forwardEmail({
|
|
979
|
+
inbox: args.inbox,
|
|
980
|
+
message_id: args.message_id,
|
|
981
|
+
to: args.to,
|
|
982
|
+
cc: args.cc,
|
|
983
|
+
bcc: args.bcc,
|
|
984
|
+
text: args.text,
|
|
985
|
+
html: args.html,
|
|
986
|
+
client_id: args.client_id,
|
|
987
|
+
});
|
|
988
|
+
return ok(renderSendOutcome("Forwarded", res), res);
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
// --- Review Loop (HITL) reads (spec §5.2) ---
|
|
992
|
+
const listReviews = defineTool({
|
|
993
|
+
name: "list_reviews",
|
|
994
|
+
title: "List reviews",
|
|
995
|
+
description: "List the review requests submitted in this account so a sending agent can monitor its submissions in the human " +
|
|
996
|
+
"review queue. Filter by `state` (one or more), `category_id`, or `inbox`. Human-authority actions (approve/reject/" +
|
|
997
|
+
"edit-send) happen in the console, never via tools.",
|
|
998
|
+
inputSchema: {
|
|
999
|
+
state: z
|
|
1000
|
+
.union([
|
|
1001
|
+
z.enum(REVIEW_STATES),
|
|
1002
|
+
z.array(z.enum(REVIEW_STATES)),
|
|
1003
|
+
])
|
|
1004
|
+
.optional()
|
|
1005
|
+
.describe("Filter by one or more review states (e.g. needs_review)."),
|
|
1006
|
+
category_id: z.string().optional().describe("Filter by opaque category id (cat_…)."),
|
|
1007
|
+
inbox: z.string().optional().describe("Filter by composer inbox address."),
|
|
1008
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max reviews to return."),
|
|
1009
|
+
page: z.string().optional().describe("Opaque page cursor from a previous call."),
|
|
1010
|
+
},
|
|
1011
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1012
|
+
handler: async (args, { client }) => {
|
|
1013
|
+
const pageResult = await client.listReviews({
|
|
1014
|
+
state: args.state,
|
|
1015
|
+
category_id: args.category_id,
|
|
1016
|
+
inbox: args.inbox,
|
|
1017
|
+
limit: args.limit,
|
|
1018
|
+
page: args.page,
|
|
1019
|
+
});
|
|
1020
|
+
const text = pageResult.items.length
|
|
1021
|
+
? pageResult.items.map(renderReview).join("\n\n")
|
|
1022
|
+
: "No reviews match.";
|
|
1023
|
+
return ok(`${pageResult.items.length} review(s).\n\n${text}`, {
|
|
1024
|
+
items: pageResult.items,
|
|
1025
|
+
total: pageResult.total,
|
|
1026
|
+
});
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
const getReview = defineTool({
|
|
1030
|
+
name: "get_review",
|
|
1031
|
+
title: "Get review",
|
|
1032
|
+
description: "Fetch one review request by id (rr_…): its current state, `revision` (pass it as submit_revision's " +
|
|
1033
|
+
"parent_revision), the proposed draft, the intent, the category, and (once sent) the sent body + diff.\n\n" +
|
|
1034
|
+
"This is the DEFINITIVE per-review 'am I done?' answer, and the poll-side companion to the event drain — use it " +
|
|
1035
|
+
"after a crash, when your event cursor is gone. `closed` is true for sent, auto_sent, cancelled AND failed " +
|
|
1036
|
+
"(failed is absorbing: nobody will ever move that row, so waiting on it hangs forever). `send_path` says how it " +
|
|
1037
|
+
"got out; `send_error` says why it did not.",
|
|
1038
|
+
inputSchema: {
|
|
1039
|
+
id: z.string().min(1).describe("Review id (rr_…) from list_reviews or a queued send/reply."),
|
|
1040
|
+
},
|
|
1041
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1042
|
+
handler: async (args, { client }) => {
|
|
1043
|
+
const review = await client.getReview(args.id);
|
|
1044
|
+
return ok(renderReview(review), review);
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
const getReviewTurns = defineTool({
|
|
1048
|
+
name: "get_review_turns",
|
|
1049
|
+
title: "Get review thread turns",
|
|
1050
|
+
description: "Fetch the append-only thread turns for a review (rr_…): the intent, every draft revision, human comments/edits/" +
|
|
1051
|
+
"decisions, captured diffs, and state changes — the full audit + learning trail.",
|
|
1052
|
+
inputSchema: {
|
|
1053
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1054
|
+
},
|
|
1055
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1056
|
+
handler: async (args, { client }) => {
|
|
1057
|
+
const pageResult = await client.getReviewTurns(args.id);
|
|
1058
|
+
const text = pageResult.items.length
|
|
1059
|
+
? pageResult.items.map(renderReviewTurn).join("\n")
|
|
1060
|
+
: "No turns yet.";
|
|
1061
|
+
return ok(`${pageResult.items.length} turn(s).\n\n${text}`, {
|
|
1062
|
+
items: pageResult.items,
|
|
1063
|
+
total: pageResult.total,
|
|
1064
|
+
});
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
// --- Review Loop (HITL) per-message CHAT + revision + cancel + feedback (M5) ---
|
|
1068
|
+
/** Render the human's assembled review feedback (diff + comments + decision + new rules). */
|
|
1069
|
+
function renderReviewFeedback(f) {
|
|
1070
|
+
const lines = [`feedback for ${f.review_id} — decision: ${f.decision}`];
|
|
1071
|
+
if (f.diff_unified)
|
|
1072
|
+
lines.push(`diff:\n${f.diff_unified}`);
|
|
1073
|
+
for (const c of f.comments) {
|
|
1074
|
+
lines.push(`• [${c.actor_kind}] ${c.body.replace(/\s+/g, " ").slice(0, 200)}`);
|
|
1075
|
+
}
|
|
1076
|
+
if (f.new_rules.length)
|
|
1077
|
+
lines.push(`rules born from this review: ${f.new_rules.join(", ")}`);
|
|
1078
|
+
return lines.join("\n");
|
|
1079
|
+
}
|
|
1080
|
+
/** Render the reviewer's decision context (intent + draft + breaker budget). */
|
|
1081
|
+
function renderDecisionContext(dc) {
|
|
1082
|
+
const lines = [renderReview(dc.review)];
|
|
1083
|
+
lines.push(` breakers: hops ${dc.hop_count}/${dc.max_hops}${dc.hops_exhausted ? " (EXHAUSTED)" : ""}` +
|
|
1084
|
+
` · deadline ${dc.review_deadline}${dc.deadline_passed ? " (PASSED)" : ""}`);
|
|
1085
|
+
if (dc.force_to_human) {
|
|
1086
|
+
lines.push(` ⚠ a reject will be FORCED to the human (${dc.force_reason}) — the human is the only terminal authority.`);
|
|
1087
|
+
}
|
|
1088
|
+
if (dc.turns.length) {
|
|
1089
|
+
lines.push(" thread:");
|
|
1090
|
+
for (const t of dc.turns.slice(-6))
|
|
1091
|
+
lines.push(` ${renderReviewTurn(t)}`);
|
|
1092
|
+
}
|
|
1093
|
+
return lines.join("\n");
|
|
1094
|
+
}
|
|
1095
|
+
/** Render a reviewer-decision outcome. */
|
|
1096
|
+
function renderReviewerDecision(res) {
|
|
1097
|
+
if (res.sent) {
|
|
1098
|
+
return `Sent via the composer's creds (reviewer_approved).\n${renderReview(res.review)}${res.message_id ? `\n message: ${res.message_id}` : ""}`;
|
|
1099
|
+
}
|
|
1100
|
+
const forced = res.forced_by_breaker ? ` (FORCED by ${res.forced_by_breaker})` : "";
|
|
1101
|
+
return `Returned to the human queue${forced}.\n${renderReview(res.review)}`;
|
|
1102
|
+
}
|
|
1103
|
+
const getReviewDecisionContext = defineTool({
|
|
1104
|
+
name: "get_review_decision_context",
|
|
1105
|
+
title: "Get a review's decision context (reviewer)",
|
|
1106
|
+
description: "REVIEWER PLANE (review:act): fetch your read-only decision surface for a review (rr_…) you're linked to — the intent + " +
|
|
1107
|
+
"current draft + the append-only thread + the TWO circuit-breaker budgets (hop_count vs max_hops, and the hard " +
|
|
1108
|
+
"review_deadline). force_to_human=true means a reject would be FORCED to the human regardless of your intent (the human " +
|
|
1109
|
+
"is the only terminal authority, D17). You can only see reviews your active review-link covers (per-inbox beats " +
|
|
1110
|
+
"account-wide); review:act alone is not enough. Read this, then reviewer_decide. $0 LLM — pure assembly on our side.",
|
|
1111
|
+
inputSchema: {
|
|
1112
|
+
id: z.string().min(1).describe("Review id (rr_…) you are the linked reviewer for."),
|
|
1113
|
+
},
|
|
1114
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1115
|
+
handler: async (args, { client }) => {
|
|
1116
|
+
const dc = await client.getReviewDecisionContext(args.id);
|
|
1117
|
+
return ok(renderDecisionContext(dc), dc);
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
const reviewerDecide = defineTool({
|
|
1121
|
+
name: "reviewer_decide",
|
|
1122
|
+
title: "Decide a review as the linked reviewer",
|
|
1123
|
+
description: "REVIEWER PLANE (review:act): submit your decision on a review (rr_…) you're linked to. action=approve|edit|reject|" +
|
|
1124
|
+
"escalate. approve/edit → the PLATFORM sends with the COMPOSER's credentials (you NEVER hold mailbox:send on an inbox " +
|
|
1125
|
+
"you don't own — the credential boundary); edit also supplies a new subject/body. reject → back to the composer to " +
|
|
1126
|
+
"redraft (hop_count++). escalate → straight to the human queue. revision is the CAS — it MUST equal the draft's current " +
|
|
1127
|
+
"revision (from get_review_decision_context): a mismatch is a 409 STALE with NO change (re-read, re-decide; the human " +
|
|
1128
|
+
"always wins, D17). The two circuit breakers (hop_count ≥ max_hops, or the hard review_deadline) FORCE a reject to the " +
|
|
1129
|
+
"human regardless of your intent — the result's forced_by_breaker names it. $0 LLM — YOU judge; we route, send, and " +
|
|
1130
|
+
"enforce the breakers.",
|
|
1131
|
+
inputSchema: {
|
|
1132
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1133
|
+
action: z.enum(["approve", "edit", "reject", "escalate"]).describe("approve | edit | reject | escalate."),
|
|
1134
|
+
revision: z.number().int().min(0).describe("The revision you decided against (PRIMARY CAS; 409 STALE on mismatch)."),
|
|
1135
|
+
version: z.number().int().optional().describe("Optional row-version CAS (defense in depth)."),
|
|
1136
|
+
subject: z.string().optional().describe("Edited subject (edit action)."),
|
|
1137
|
+
body: z.string().optional().describe("Edited body text (edit action)."),
|
|
1138
|
+
feedback: z.string().optional().describe("Your note (reject: the rule-birth signal; escalate: the human-facing reason)."),
|
|
1139
|
+
},
|
|
1140
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1141
|
+
handler: async (args, { client }) => {
|
|
1142
|
+
const res = await client.reviewerDecide({
|
|
1143
|
+
id: args.id,
|
|
1144
|
+
action: args.action,
|
|
1145
|
+
revision: args.revision,
|
|
1146
|
+
version: args.version,
|
|
1147
|
+
subject: args.subject,
|
|
1148
|
+
body: args.body,
|
|
1149
|
+
feedback: args.feedback,
|
|
1150
|
+
});
|
|
1151
|
+
return ok(renderReviewerDecision(res), res);
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
const postReviewChat = defineTool({
|
|
1155
|
+
name: "post_review_chat",
|
|
1156
|
+
title: "Post a chat turn on a review",
|
|
1157
|
+
description: "Ask the human reviewer a clarifying question on a review's thread (rr_…) — append an agent_question turn.\n\n" +
|
|
1158
|
+
"LEGAL FROM: needs_review, in_review, chatting. A question on a needs_review draft does NOT open it: the draft " +
|
|
1159
|
+
"stays in the human queue, no reviewer is assigned, and no nudge is sent. Only an in_review draft flips to " +
|
|
1160
|
+
"chatting. (A HUMAN comment on a needs_review draft DOES open it — that asymmetry is deliberate: an agent must " +
|
|
1161
|
+
"not be able to pull a draft out of the queue by asking a question.)\n\n" +
|
|
1162
|
+
"The human sees your question on the console stream and replies with a comment (read it via get_review_turns / " +
|
|
1163
|
+
"get_review_feedback). Idempotent on client_id (the Idempotency-Key). Use this when you are UNSURE what the human " +
|
|
1164
|
+
"wants; otherwise just submit_revision a redraft. $0 LLM — YOU compose the question.",
|
|
1165
|
+
inputSchema: {
|
|
1166
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1167
|
+
text: z.string().min(1).describe("Your question/comment for the human reviewer."),
|
|
1168
|
+
client_id: z.string().optional().describe("Idempotency key (Idempotency-Key); a retry with the same key never doubles the turn."),
|
|
1169
|
+
},
|
|
1170
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1171
|
+
handler: async (args, { client }) => {
|
|
1172
|
+
const review = await client.postReviewChat({ id: args.id, text: args.text, client_id: args.client_id });
|
|
1173
|
+
return ok(`Posted.\n${renderReview(review)}`, review);
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
const submitRevision = defineTool({
|
|
1177
|
+
name: "submit_revision",
|
|
1178
|
+
title: "Submit a redrafted revision",
|
|
1179
|
+
description: "Post a NEW draft for a review (rr_…) under a parent_revision CAS — the way to redraft after feedback, a chat " +
|
|
1180
|
+
"answer, or a rule_changed / recheck_category nudge. `parent_revision` MUST equal the draft's current `revision`, " +
|
|
1181
|
+
"which get_review prints.\n\n" +
|
|
1182
|
+
"LEGAL FROM: needs_review, in_review, chatting, rejected. needs_review IS legal — a reviewer reject, a born-stale " +
|
|
1183
|
+
"rule change and a recheck_category nudge all hand the draft back to you sitting in needs_review, and redrafting " +
|
|
1184
|
+
"it is exactly what you are being asked to do. On success the draft is re-rendered in place (revision++), stays/" +
|
|
1185
|
+
"returns to needs_review, and the reviewer is nudged.\n\n" +
|
|
1186
|
+
"The three 409s are DIFFERENT errors — read `code`, not just the status:\n" +
|
|
1187
|
+
" • `stale` — your (revision, version) is no longer current and NOTHING was mutated. RETRY, bounded (≤3): " +
|
|
1188
|
+
"re-read get_review + get_review_feedback, re-apply your edit on top of theirs, resubmit with the new " +
|
|
1189
|
+
"parent_revision. The human always wins (D17).\n" +
|
|
1190
|
+
" • `wrong_state` — this verb is illegal from the current state but the draft is still live. NEVER retry it; " +
|
|
1191
|
+
"read the allowed_action hints in the error and pick a legal verb.\n" +
|
|
1192
|
+
" • `terminal` — the review is sent/auto_sent/cancelled. STOP. Nothing will ever succeed, and a `front_run_next` " +
|
|
1193
|
+
"event is waiting in your drain.\n\n" +
|
|
1194
|
+
"Pin `rules_version_seen` to the category's rule_high_water (get_category prints it). $0 LLM — YOU compose the " +
|
|
1195
|
+
"redraft.",
|
|
1196
|
+
inputSchema: {
|
|
1197
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1198
|
+
parent_revision: z
|
|
1199
|
+
.number()
|
|
1200
|
+
.int()
|
|
1201
|
+
.min(0)
|
|
1202
|
+
.describe("The revision you composed against, from get_review's `revision` (PRIMARY CAS; 409 `stale` on mismatch)."),
|
|
1203
|
+
version: z.number().int().optional().describe("Optional row-version CAS (defense in depth)."),
|
|
1204
|
+
subject: z.string().optional().describe("New subject."),
|
|
1205
|
+
text: z.string().optional().describe("New body text (canonical — matches send/reply/forward's `text`)."),
|
|
1206
|
+
body: z
|
|
1207
|
+
.string()
|
|
1208
|
+
.optional()
|
|
1209
|
+
.describe("DEPRECATED alias for `text`, still accepted. Sending both with different content is rejected."),
|
|
1210
|
+
html: z.string().optional().describe("New HTML body."),
|
|
1211
|
+
attachments: z
|
|
1212
|
+
.array(attachmentInput)
|
|
1213
|
+
.max(20)
|
|
1214
|
+
.optional()
|
|
1215
|
+
.describe("REPLACES the draft's attachments. Omit to leave them untouched; pass [] to clear them. Without this a " +
|
|
1216
|
+
"redraft could never restore a file the human reviewed the message with."),
|
|
1217
|
+
built_at: z.string().optional().describe("When you built this draft (informational)."),
|
|
1218
|
+
rules_version_seen: z.number().int().optional().describe("Rule high-water this draft was composed against (born-stale basis)."),
|
|
1219
|
+
client_id: z
|
|
1220
|
+
.string()
|
|
1221
|
+
.min(1)
|
|
1222
|
+
.max(128)
|
|
1223
|
+
.optional()
|
|
1224
|
+
.describe("Stable Idempotency-Key for this exact revision. Reuse it after a transport timeout."),
|
|
1225
|
+
},
|
|
1226
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1227
|
+
handler: async (args, { client }) => {
|
|
1228
|
+
const review = await client.submitRevision({
|
|
1229
|
+
id: args.id,
|
|
1230
|
+
parent_revision: args.parent_revision,
|
|
1231
|
+
version: args.version,
|
|
1232
|
+
subject: args.subject,
|
|
1233
|
+
text: args.text,
|
|
1234
|
+
body: args.body,
|
|
1235
|
+
html: args.html,
|
|
1236
|
+
attachments: args.attachments,
|
|
1237
|
+
built_at: args.built_at,
|
|
1238
|
+
rules_version_seen: args.rules_version_seen,
|
|
1239
|
+
client_id: args.client_id,
|
|
1240
|
+
});
|
|
1241
|
+
return ok(`Revised.\n${renderReview(review)}`, review);
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
const cancelReview = defineTool({
|
|
1245
|
+
name: "cancel_review",
|
|
1246
|
+
title: "Withdraw a pending review",
|
|
1247
|
+
description: "Withdraw your own pending review (rr_…) to the terminal cancelled state — you decided not to send it after all. " +
|
|
1248
|
+
"Only the composing agent may cancel its own review. It is also the ONLY legal close-out for a `failed` review: " +
|
|
1249
|
+
"after a send_failed event the row cannot be retried by anyone, so cancel it and compose a NEW message.\n\n" +
|
|
1250
|
+
"An already-terminal (sent/auto_sent/cancelled) review answers 409 `terminal` — STOP, do not retry. An `approved` " +
|
|
1251
|
+
"review answers 409 `wrong_state`: it is mid-delivery, so wait for the `sent` or `send_failed` event instead.",
|
|
1252
|
+
inputSchema: {
|
|
1253
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1254
|
+
client_id: z
|
|
1255
|
+
.string()
|
|
1256
|
+
.min(1)
|
|
1257
|
+
.max(128)
|
|
1258
|
+
.optional()
|
|
1259
|
+
.describe("Stable Idempotency-Key for this cancellation. Reuse it after a transport timeout."),
|
|
1260
|
+
},
|
|
1261
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1262
|
+
handler: async (args, { client }) => {
|
|
1263
|
+
const review = await client.cancelReview({ id: args.id, client_id: args.client_id });
|
|
1264
|
+
return ok(`Cancelled.\n${renderReview(review)}`, review);
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
const restampReview = defineTool({
|
|
1268
|
+
name: "restamp_review",
|
|
1269
|
+
title: "Re-stamp a draft's rules-version without redrafting ($0)",
|
|
1270
|
+
description: "The $0 escape valve when a rule_changed/recheck nudge fires AND the draft genuinely already complies (D19/§8). " +
|
|
1271
|
+
"Use it ONLY for 'I read the new rules and no change is needed'. If the draft DOES need to change, use " +
|
|
1272
|
+
"submit_revision — re-stamping a draft that should have been redrafted makes you lie to the born-stale " +
|
|
1273
|
+
"accounting, and the reconciliation sweep will then RELEASE a pre-rule draft to a human as if it were current. " +
|
|
1274
|
+
"Instead of an " +
|
|
1275
|
+
"expensive redraft, assert 'I reviewed this against rules vX and no change is needed' — the server advances the draft's " +
|
|
1276
|
+
"composed_* rules-versions to vX WITHOUT a new draft (no revision bump, no body change, no nudge). A born-stale draft " +
|
|
1277
|
+
"re-stamped to the CURRENT version becomes current-enough and is releasable on the next reconciliation sweep. " +
|
|
1278
|
+
"against_version must NOT exceed the category's current rules-version (you can't claim a version that doesn't exist). " +
|
|
1279
|
+
"Use submit_revision instead when the draft DOES need to change. $0 LLM — you judged.",
|
|
1280
|
+
inputSchema: {
|
|
1281
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1282
|
+
against_version: z
|
|
1283
|
+
.number()
|
|
1284
|
+
.int()
|
|
1285
|
+
.min(0)
|
|
1286
|
+
.describe("The category rules-version you reviewed against (≤ the category's current rules-version)."),
|
|
1287
|
+
house_style_version: z
|
|
1288
|
+
.number()
|
|
1289
|
+
.int()
|
|
1290
|
+
.optional()
|
|
1291
|
+
.describe("Optional: re-stamp the house-style axis to this version (≤ the org's current house_style_version)."),
|
|
1292
|
+
client_id: z
|
|
1293
|
+
.string()
|
|
1294
|
+
.min(1)
|
|
1295
|
+
.max(128)
|
|
1296
|
+
.optional()
|
|
1297
|
+
.describe("Stable Idempotency-Key for this exact re-stamp. Reuse it after a transport timeout."),
|
|
1298
|
+
},
|
|
1299
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1300
|
+
handler: async (args, { client }) => {
|
|
1301
|
+
const review = await client.restampReview({
|
|
1302
|
+
id: args.id,
|
|
1303
|
+
against_version: args.against_version,
|
|
1304
|
+
house_style_version: args.house_style_version,
|
|
1305
|
+
client_id: args.client_id,
|
|
1306
|
+
});
|
|
1307
|
+
return ok(`Re-stamped (no redraft).\n${renderReview(review)}`, review);
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
const getReviewFeedback = defineTool({
|
|
1311
|
+
name: "get_review_feedback",
|
|
1312
|
+
title: "Get the human's feedback on a review",
|
|
1313
|
+
description: "Fetch the human's assembled feedback for a review (rr_…): the unified + structured diff of the human's edit, the human " +
|
|
1314
|
+
"comments / rejection feedback, the decision (edited|approved|rejected|…), and the rules already born from this review. " +
|
|
1315
|
+
"Read this after a rejected/edited nudge to learn what the human wanted, then judge whether a generalizable rule exists " +
|
|
1316
|
+
"(save_rule) and/or submit_revision a redraft. $0 LLM — pure assembly on our side.",
|
|
1317
|
+
inputSchema: {
|
|
1318
|
+
id: z.string().min(1).describe("Review id (rr_…)."),
|
|
1319
|
+
},
|
|
1320
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1321
|
+
handler: async (args, { client }) => {
|
|
1322
|
+
const fb = await client.getReviewFeedback(args.id);
|
|
1323
|
+
return ok(renderReviewFeedback(fb), fb);
|
|
1324
|
+
},
|
|
1325
|
+
});
|
|
1326
|
+
// --- Category registry (Review Loop, D9/D10) — browse / propose / curate -----
|
|
1327
|
+
const CATEGORY_SCOPES = ["org_shared", "agent_private"];
|
|
1328
|
+
const listCategories = defineTool({
|
|
1329
|
+
name: "list_categories",
|
|
1330
|
+
title: "Browse the category registry",
|
|
1331
|
+
description: "Browse the categories in this account (id + name + description + scope + state) so you can MATCH an existing " +
|
|
1332
|
+
"category before composing a new one — like a skills registry. The optional `match` is a pure lexical/substring " +
|
|
1333
|
+
"filter (every word must appear in the name+description); it does NO semantic matching — YOU read the descriptions " +
|
|
1334
|
+
"and pick the best fit. Categories are shared across the account's agents. Use the returned cat_ id (never the " +
|
|
1335
|
+
"name) as category_id on send/reply.",
|
|
1336
|
+
inputSchema: {
|
|
1337
|
+
match: z.string().optional().describe("Lexical substring filter over name+description (every word must match)."),
|
|
1338
|
+
},
|
|
1339
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1340
|
+
handler: async (args, { client }) => {
|
|
1341
|
+
const pageResult = await client.listCategories(args.match);
|
|
1342
|
+
const text = pageResult.items.length
|
|
1343
|
+
? pageResult.items.map(renderCategory).join("\n\n")
|
|
1344
|
+
: "No categories match. Propose one with propose_category if none fits.";
|
|
1345
|
+
return ok(`${pageResult.items.length} categor${pageResult.items.length === 1 ? "y" : "ies"}.\n\n${text}`, {
|
|
1346
|
+
items: pageResult.items,
|
|
1347
|
+
total: pageResult.total,
|
|
1348
|
+
});
|
|
1349
|
+
},
|
|
1350
|
+
});
|
|
1351
|
+
const getCategory = defineTool({
|
|
1352
|
+
name: "get_category",
|
|
1353
|
+
title: "Get category",
|
|
1354
|
+
description: "Fetch one category by id (cat_…): its name, description, scope, and graduation state.",
|
|
1355
|
+
inputSchema: {
|
|
1356
|
+
id: z.string().min(1).describe("Category id (cat_…) from list_categories."),
|
|
1357
|
+
},
|
|
1358
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1359
|
+
handler: async (args, { client }) => {
|
|
1360
|
+
const cat = await client.getCategory(args.id);
|
|
1361
|
+
return ok(renderCategory(cat), cat);
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
const proposeCategory = defineTool({
|
|
1365
|
+
name: "propose_category",
|
|
1366
|
+
title: "Propose a category",
|
|
1367
|
+
description: "Propose a NEW category with a name + a skill-style description (D9). It stands immediately and is shared across " +
|
|
1368
|
+
"the account's agents. ONLY propose after browsing the registry with list_categories and finding no good match — " +
|
|
1369
|
+
"duplicates fragment the rules. Returns the cat_ id to use as category_id on send/reply.",
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
name: z.string().min(1).describe("Display name (mutable; never used as a reference key)."),
|
|
1372
|
+
description: z.string().optional().describe("Skill-style matcher text describing when this category applies."),
|
|
1373
|
+
scope: z.enum(CATEGORY_SCOPES).optional().describe("org_shared (default) or agent_private."),
|
|
1374
|
+
},
|
|
1375
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1376
|
+
handler: async (args, { client }) => {
|
|
1377
|
+
const cat = await client.proposeCategory({ name: args.name, description: args.description, scope: args.scope });
|
|
1378
|
+
return ok(`Proposed.\n${renderCategory(cat)}`, cat);
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
const updateCategory = defineTool({
|
|
1382
|
+
name: "update_category",
|
|
1383
|
+
title: "Rename / re-describe a category",
|
|
1384
|
+
description: "Update a category's name and/or description — metadata ONLY (D10). Renaming never breaks a reference because " +
|
|
1385
|
+
"nothing keys on the name. Any agent in the account may edit; a rename/redescribe entry is written to the audit log. " +
|
|
1386
|
+
"Merging or deleting a category is a human (console) action, never a tool.",
|
|
1387
|
+
inputSchema: {
|
|
1388
|
+
id: z.string().min(1).describe("Category id (cat_…)."),
|
|
1389
|
+
name: z.string().optional().describe("New display name."),
|
|
1390
|
+
description: z.string().optional().describe("New skill-style description."),
|
|
1391
|
+
},
|
|
1392
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1393
|
+
handler: async (args, { client }) => {
|
|
1394
|
+
const cat = await client.updateCategory({ id: args.id, name: args.name, description: args.description });
|
|
1395
|
+
return ok(`Updated.\n${renderCategory(cat)}`, cat);
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
// --- Graduation + risk dial (Review Loop, D16/D6/D17) — agent READ + PROPOSE ----
|
|
1399
|
+
/** Render a one-line summary of the graduation gate status. */
|
|
1400
|
+
function renderGraduationStatus(st) {
|
|
1401
|
+
const next = st.next_state ? `→ ${st.next_state}` : "(top rung)";
|
|
1402
|
+
const gate = st.next_state === "auto_silent"
|
|
1403
|
+
? ` · maturity ${st.maturity_gate_met ? "MET" : "unmet"} (approvals ${st.clean_approval_count}/${st.graduate_min_approvals}, age ${st.age_met ? "ok" : "young"})`
|
|
1404
|
+
: "";
|
|
1405
|
+
const lock = st.never_graduate ? " · LOCKED (never_graduate)" : "";
|
|
1406
|
+
return `${st.category_id} [${st.state}] ${next}${gate} · drift ${st.drift_count}/${st.drift_demote_after} · can_graduate=${st.can_graduate}${lock}`;
|
|
1407
|
+
}
|
|
1408
|
+
const getRiskDial = defineTool({
|
|
1409
|
+
name: "get_risk_dial",
|
|
1410
|
+
title: "Read the effective risk dial",
|
|
1411
|
+
description: "Read the brand-risk dial that governs auto-send: the account-wide default plus every category's per-category " +
|
|
1412
|
+
"overrides (each with its RESOLVED effective value — the override applied over the account default; a null override " +
|
|
1413
|
+
"means the category inherits that value, D12). Fields: min_confidence, first_contact_gate, drift_demote_after (K), " +
|
|
1414
|
+
"canary_rate, graduate_min_approvals + graduate_min_age_hours (the maturity gate), auto_send_cap_per_day (the per-day " +
|
|
1415
|
+
"volume cap). READ-ONLY — you can never flip the dial; setting it is a human (console) action (D16).",
|
|
1416
|
+
inputSchema: {},
|
|
1417
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1418
|
+
handler: async (_args, { client }) => {
|
|
1419
|
+
const dial = await client.getRiskDial();
|
|
1420
|
+
const lines = [
|
|
1421
|
+
`Account default: min_confidence ${dial.account.min_confidence}, canary ${dial.account.canary_rate}, ` +
|
|
1422
|
+
`K ${dial.account.drift_demote_after}, maturity ${dial.account.graduate_min_approvals} approvals / ` +
|
|
1423
|
+
`${dial.account.graduate_min_age_hours}h, cap ${dial.account.auto_send_cap_per_day}/day.`,
|
|
1424
|
+
dial.categories.length
|
|
1425
|
+
? dial.categories
|
|
1426
|
+
.map((c) => `• ${c.category_id}: min_confidence ${c.effective.min_confidence}` +
|
|
1427
|
+
(c.min_confidence === null ? " (inherited)" : " (override)"))
|
|
1428
|
+
.join("\n")
|
|
1429
|
+
: "No category overrides.",
|
|
1430
|
+
];
|
|
1431
|
+
return ok(lines.join("\n"), dial);
|
|
1432
|
+
},
|
|
1433
|
+
});
|
|
1434
|
+
const getGraduationStatus = defineTool({
|
|
1435
|
+
name: "get_graduation_status",
|
|
1436
|
+
title: "Read a category's graduation status",
|
|
1437
|
+
description: "Read whether a category is ready to graduate to the NEXT rung (supervised→auto_notify→auto_silent). Reports the " +
|
|
1438
|
+
"gates passed / still needed: clean approvals (N / needed), category age, the maturity gate (required for auto_silent, " +
|
|
1439
|
+
"D16), the drift counter vs K, and can_graduate (would a human graduate succeed right now). Use this to decide when " +
|
|
1440
|
+
"to propose_graduation — you can never flip the bit yourself (a human confirms; D16/D6).",
|
|
1441
|
+
inputSchema: {
|
|
1442
|
+
id: z.string().min(1).describe("Category id (cat_…)."),
|
|
1443
|
+
},
|
|
1444
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1445
|
+
handler: async (args, { client }) => {
|
|
1446
|
+
const st = await client.getGraduationStatus(args.id);
|
|
1447
|
+
return ok(renderGraduationStatus(st), st);
|
|
1448
|
+
},
|
|
1449
|
+
});
|
|
1450
|
+
const getBacklogStatus = defineTool({
|
|
1451
|
+
name: "get_backlog_status",
|
|
1452
|
+
title: "Read the D19 backlog-reconciliation status",
|
|
1453
|
+
description: "Read the category's backlog reconciliation picture (D19/§8): how many of its QUEUED drafts are STALE (composed under " +
|
|
1454
|
+
"older rules — they need a redraft) vs CURRENT-ENOUGH (within tolerance of the current rules-version) against the " +
|
|
1455
|
+
"current category rules-version + house-style version. A pure $0-LLM integer compare. Read-only — you READ the picture; " +
|
|
1456
|
+
"the human (console scan-backlog) or the graduate/rule-change hooks TRIGGER the actual sweep that releases current-enough " +
|
|
1457
|
+
"drafts and nudges stale ones. Use it to see whether your queued drafts are about to be re-checked.",
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
id: z.string().min(1).describe("Category id (cat_…)."),
|
|
1460
|
+
},
|
|
1461
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1462
|
+
handler: async (args, { client }) => {
|
|
1463
|
+
const st = await client.getBacklogStatus(args.id);
|
|
1464
|
+
const text = `backlog for ${st.category_id} (${st.state}): ${st.queued} queued — ` +
|
|
1465
|
+
`${st.current_enough} current-enough, ${st.stale} stale ` +
|
|
1466
|
+
`(category rules v${st.current_category_rules_version}, house-style v${st.current_house_style_version}, ` +
|
|
1467
|
+
`tolerance ${st.staleness_tolerance})`;
|
|
1468
|
+
return ok(text, st);
|
|
1469
|
+
},
|
|
1470
|
+
});
|
|
1471
|
+
const getPacingState = defineTool({
|
|
1472
|
+
name: "get_pacing_state",
|
|
1473
|
+
title: "Read the demand-driven pacing state for a category",
|
|
1474
|
+
description: "Read the category's demand-driven pacing snapshot (M7 Slice B/§8): the human review CURSOR position, the effective " +
|
|
1475
|
+
"lookahead window (freshness is guaranteed only for the next few drafts after the cursor), the HARD per-nudge fan-out " +
|
|
1476
|
+
"ceiling (rework_batch_max — one nudge can never fan to 500), the per-agent nudge interval, and each queued draft's " +
|
|
1477
|
+
"classification (behind_cursor | in_window_fresh | in_window_redrafting | ahead). A pure $0-LLM read; the cursor advances " +
|
|
1478
|
+
"from the human's console approve/reject/edit actions. Use it to see which of your drafts are about to surface (and should " +
|
|
1479
|
+
"be redrafted against current rules) vs already passed.",
|
|
1480
|
+
inputSchema: {
|
|
1481
|
+
id: z.string().min(1).describe("Category id (cat_…)."),
|
|
1482
|
+
},
|
|
1483
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1484
|
+
handler: async (args, { client }) => {
|
|
1485
|
+
const st = await client.getPacingState(args.id);
|
|
1486
|
+
const text = `pacing for ${st.category_id}: cursor ${st.cursor_review_id ?? "(start)"} ` +
|
|
1487
|
+
`(advanced ${st.cursor_advanced_count}×) — ${st.queued} queued, ${st.in_window} in-window ` +
|
|
1488
|
+
`(${st.redrafting} redrafting), window ${st.lookahead_window}, ceiling ${st.rework_batch_max}, ` +
|
|
1489
|
+
`interval ${st.nudge_min_interval_ms}ms`;
|
|
1490
|
+
return ok(text, st);
|
|
1491
|
+
},
|
|
1492
|
+
});
|
|
1493
|
+
const proposeGraduation = defineTool({
|
|
1494
|
+
name: "propose_graduation",
|
|
1495
|
+
title: "Propose graduating a category",
|
|
1496
|
+
description: "PROPOSE graduating a category (D16/D6): records your request (with optional evidence) for a human to review and " +
|
|
1497
|
+
"returns the current gate status. It does NOT change the category state — flipping the graduation bit is a human " +
|
|
1498
|
+
"(console) action; an agent can only propose. A never_graduate category stays locked. Check get_graduation_status " +
|
|
1499
|
+
"first so you only propose when the gates are (nearly) met.",
|
|
1500
|
+
inputSchema: {
|
|
1501
|
+
id: z.string().min(1).describe("Category id (cat_…)."),
|
|
1502
|
+
evidence: z
|
|
1503
|
+
.record(z.unknown())
|
|
1504
|
+
.optional()
|
|
1505
|
+
.describe("Optional structured evidence for the human (e.g. {approvals: 20, last_edits: 0})."),
|
|
1506
|
+
},
|
|
1507
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1508
|
+
handler: async (args, { client }) => {
|
|
1509
|
+
const st = await client.proposeGraduation(args.id, args.evidence);
|
|
1510
|
+
return ok(`Requested (a human confirms).\n${renderGraduationStatus(st)}`, st);
|
|
1511
|
+
},
|
|
1512
|
+
});
|
|
1513
|
+
// --- Writing rules + house-style + precedence ladder + audit/undo (D2/D11) ---
|
|
1514
|
+
const RULE_SCOPES = ["general", "category"];
|
|
1515
|
+
const RULE_KINDS = ["soft", "hard"];
|
|
1516
|
+
const AUDIT_ENTITY_KINDS = ["rule", "category"];
|
|
1517
|
+
const getRules = defineTool({
|
|
1518
|
+
name: "get_rules",
|
|
1519
|
+
title: "Get the ordered writing-rule set",
|
|
1520
|
+
description: "Get the ORDERED active writing rules for a compose/redraft. The §7 precedence ladder is applied SERVER-SIDE " +
|
|
1521
|
+
"(deterministic, NO LLM on our side): a project-layer rule outranks the broader org-layer (house-style) rules it " +
|
|
1522
|
+
"inherits; within a layer, hard before soft; specificity per-agent > category > general; human before agent; newest " +
|
|
1523
|
+
"rev; higher priority. Each rule carries its rule_layer (org | project) so you can see where it came from. Returns " +
|
|
1524
|
+
"the general/house-style layer IN ADDITION to the named category's rules (category rules first), capped. YOU reconcile " +
|
|
1525
|
+
"the list semantically and write the draft; we never apply a rule. Pass category_id to include that category's rules; " +
|
|
1526
|
+
"omit it for ONLY the house-style layer.",
|
|
1527
|
+
inputSchema: {
|
|
1528
|
+
category_id: z.string().optional().describe("Category id (cat_…). Empty returns ONLY house-style/general rules."),
|
|
1529
|
+
scope: z.enum(RULE_SCOPES).optional().describe("Narrow to one layer (general | category). Default returns both."),
|
|
1530
|
+
},
|
|
1531
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1532
|
+
handler: async (args, { client }) => {
|
|
1533
|
+
const res = await client.getRules({ category_id: args.category_id, scope: args.scope });
|
|
1534
|
+
const text = res.items.length ? res.items.map(renderRule).join("\n\n") : "No rules yet.";
|
|
1535
|
+
return ok(`${res.items.length} rule(s), highest precedence first.\n\n${text}`, {
|
|
1536
|
+
items: res.items,
|
|
1537
|
+
total: res.total,
|
|
1538
|
+
});
|
|
1539
|
+
},
|
|
1540
|
+
});
|
|
1541
|
+
const saveRule = defineTool({
|
|
1542
|
+
name: "save_rule",
|
|
1543
|
+
title: "Save / edit a writing rule",
|
|
1544
|
+
description: "Save a learned writing rule (D11; ANY agent may write shared rules within its project — the audit log + undo is the " +
|
|
1545
|
+
"safety net). scope='general' iff category_id is empty (house-style, applies to ALL categories — D2); else " +
|
|
1546
|
+
"category-scoped. Saves are ALWAYS project-layer: the new rule is bound to this key's fixed project (see whoami) and " +
|
|
1547
|
+
"its rule_layer is 'project'. Org-layer / org-wide house-style rules are console/admin-only in v1 — an agent cannot " +
|
|
1548
|
+
"create them here. With supersedes_id the write is an EDIT (append-only by supersession: a new rev of the same " +
|
|
1549
|
+
"lineage, the prior superseded). Use this AFTER you judge a diff/comment is a generalizable rule (the judgment is " +
|
|
1550
|
+
"yours; we never run an LLM). Returns the new active rule (with its rule_layer/org_id/project_id).",
|
|
1551
|
+
inputSchema: {
|
|
1552
|
+
rule_text: z.string().min(1).describe("The rule body, e.g. 'no em-dashes' or 'be more pushy, we need MRR'."),
|
|
1553
|
+
category_id: z.string().optional().describe("Category id (cat_…). Empty = house-style/general (D2)."),
|
|
1554
|
+
scope: z.enum(RULE_SCOPES).optional().describe("Defaults from category_id (general iff empty)."),
|
|
1555
|
+
kind: z.enum(RULE_KINDS).optional().describe("soft (default) or hard (non-overridable)."),
|
|
1556
|
+
priority: z.number().int().optional().describe("Higher wins the last ladder tiebreak."),
|
|
1557
|
+
source_review_id: z.string().optional().describe("Provenance: the review this rule was learned from (rr_…)."),
|
|
1558
|
+
source_turn_id: z.string().optional().describe("Provenance: the thread turn (turn_…)."),
|
|
1559
|
+
supersedes_id: z.string().optional().describe("Set to EDIT the prior version (rule_…)."),
|
|
1560
|
+
scope_agent_id: z.string().optional().describe("Set for a per-agent override; empty = all org agents."),
|
|
1561
|
+
propagate_to_pending: z
|
|
1562
|
+
.boolean()
|
|
1563
|
+
.optional()
|
|
1564
|
+
.describe("D8 retro-propagation HUMAN OPT-IN (default false). Set ONLY when the human said 'apply to N pending?'. " +
|
|
1565
|
+
"Enqueues a propagate_general_rule nudge to pending siblings of a NEW category rule so you redraft a FEW " +
|
|
1566
|
+
"at a time — never the whole queue."),
|
|
1567
|
+
suggested_batch: z
|
|
1568
|
+
.number()
|
|
1569
|
+
.int()
|
|
1570
|
+
.optional()
|
|
1571
|
+
.describe("Override the propagate batch (0 = base 3, bounded by rework_batch_max). Never fans one nudge to the whole queue."),
|
|
1572
|
+
},
|
|
1573
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1574
|
+
handler: async (args, { client }) => {
|
|
1575
|
+
const rule = await client.saveRule({
|
|
1576
|
+
rule_text: args.rule_text,
|
|
1577
|
+
category_id: args.category_id,
|
|
1578
|
+
scope: args.scope,
|
|
1579
|
+
kind: args.kind,
|
|
1580
|
+
priority: args.priority,
|
|
1581
|
+
source_review_id: args.source_review_id,
|
|
1582
|
+
source_turn_id: args.source_turn_id,
|
|
1583
|
+
supersedes_id: args.supersedes_id,
|
|
1584
|
+
scope_agent_id: args.scope_agent_id,
|
|
1585
|
+
propagate_to_pending: args.propagate_to_pending,
|
|
1586
|
+
suggested_batch: args.suggested_batch,
|
|
1587
|
+
});
|
|
1588
|
+
return ok(`Saved.\n${renderRule(rule)}`, rule);
|
|
1589
|
+
},
|
|
1590
|
+
});
|
|
1591
|
+
const promoteRule = defineTool({
|
|
1592
|
+
name: "promote_rule",
|
|
1593
|
+
title: "Promote a rule between layers",
|
|
1594
|
+
description: "Move a rule between the category and general/house-style layers (via a supersession). Promote to 'general' to make a " +
|
|
1595
|
+
"category rule apply across ALL categories (house-style); promote to 'category' to scope a general rule down. Never " +
|
|
1596
|
+
"promote to general without a human signal — house-style has account-wide blast radius (D2).",
|
|
1597
|
+
inputSchema: {
|
|
1598
|
+
id: z.string().min(1).describe("Rule id (rule_…)."),
|
|
1599
|
+
to_scope: z.enum(RULE_SCOPES).describe("general (house-style) or category."),
|
|
1600
|
+
},
|
|
1601
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1602
|
+
handler: async (args, { client }) => {
|
|
1603
|
+
const rule = await client.promoteRule(args.id, args.to_scope);
|
|
1604
|
+
return ok(`Promoted.\n${renderRule(rule)}`, rule);
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
const retireRule = defineTool({
|
|
1608
|
+
name: "retire_rule",
|
|
1609
|
+
title: "Retire a rule",
|
|
1610
|
+
description: "Soft-delete a rule (status='retired'); the history survives as training data (there is NO hard delete). Use this to " +
|
|
1611
|
+
"drop a rule that no longer applies — consolidate redundant rules by saving one merged rule and retiring the originals.",
|
|
1612
|
+
inputSchema: {
|
|
1613
|
+
id: z.string().min(1).describe("Rule id (rule_…)."),
|
|
1614
|
+
},
|
|
1615
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1616
|
+
handler: async (args, { client }) => {
|
|
1617
|
+
const rule = await client.retireRule(args.id);
|
|
1618
|
+
return ok(`Retired.\n${renderRule(rule)}`, rule);
|
|
1619
|
+
},
|
|
1620
|
+
});
|
|
1621
|
+
const getRuleAudit = defineTool({
|
|
1622
|
+
name: "get_rule_audit",
|
|
1623
|
+
title: "Read the rule/category change audit log",
|
|
1624
|
+
description: "Read the append-only change/undo audit log spanning rules AND categories (D11) — the safety net for the shared/house-" +
|
|
1625
|
+
"style rule governance. Optionally narrow to one entity. Each row carries a before/after snapshot; undo a change with " +
|
|
1626
|
+
"undo_rule_change.",
|
|
1627
|
+
inputSchema: {
|
|
1628
|
+
entity_kind: z.enum(AUDIT_ENTITY_KINDS).optional().describe("Narrow to one entity kind."),
|
|
1629
|
+
entity_id: z.string().optional().describe("Narrow to one entity (rule_… or cat_…)."),
|
|
1630
|
+
},
|
|
1631
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1632
|
+
handler: async (args, { client }) => {
|
|
1633
|
+
const res = await client.getRuleAudit({ entity_kind: args.entity_kind, entity_id: args.entity_id });
|
|
1634
|
+
const text = res.items.length ? res.items.map(renderRuleAudit).join("\n") : "No audit entries.";
|
|
1635
|
+
return ok(`${res.items.length} audit entr${res.items.length === 1 ? "y" : "ies"}.\n\n${text}`, {
|
|
1636
|
+
items: res.items,
|
|
1637
|
+
total: res.total,
|
|
1638
|
+
});
|
|
1639
|
+
},
|
|
1640
|
+
});
|
|
1641
|
+
const undoRuleChange = defineTool({
|
|
1642
|
+
name: "undo_rule_change",
|
|
1643
|
+
title: "Undo a rule change",
|
|
1644
|
+
description: "Undo a rule change by its audit-row id (udo_…): restore the prior version as a NEW forward supersession (action=" +
|
|
1645
|
+
"'restore'). Agents may undo too (the audit safety net is in both planes — D11). Idempotent: a re-undo of an already-" +
|
|
1646
|
+
"undone row is a clean 409. Find the udo_ id via get_rule_audit.",
|
|
1647
|
+
inputSchema: {
|
|
1648
|
+
udo_id: z.string().min(1).describe("Audit row id to undo (udo_…)."),
|
|
1649
|
+
},
|
|
1650
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1651
|
+
handler: async (args, { client }) => {
|
|
1652
|
+
const rule = await client.undoRuleChange(args.udo_id);
|
|
1653
|
+
return ok(`Undone — restored.\n${renderRule(rule)}`, rule);
|
|
1654
|
+
},
|
|
1655
|
+
});
|
|
1656
|
+
// --- Review Loop (HITL) realtime — durable nudge drain/ack/wait (spec §5.9) ---
|
|
1657
|
+
/**
|
|
1658
|
+
* Render one review event (durable nudge).
|
|
1659
|
+
*
|
|
1660
|
+
* The PAYLOAD is printed, not just the reason. Every reason's guidance lives in
|
|
1661
|
+
* its payload — the delivered `message_id` on `sent`, the scrubbed `error` and
|
|
1662
|
+
* `agent_retryable:false` on `send_failed`, the pacing/rule details on
|
|
1663
|
+
* `rule_changed`, the `current_revision` on `front_run_next`. A reason with no
|
|
1664
|
+
* payload tells a text-only agent that SOMETHING happened and nothing about what
|
|
1665
|
+
* to do, which makes the whole drain loop unactionable.
|
|
1666
|
+
*/
|
|
1667
|
+
function renderReviewEvent(e) {
|
|
1668
|
+
const scope = e.review_id ? ` · review: ${e.review_id}` : " · broadcast";
|
|
1669
|
+
const terminal = isTerminalReviewEvent(e.reason) ? " [TERMINAL — this review is done]" : "";
|
|
1670
|
+
const lines = [`• seq ${e.seq} · ${e.reason}${scope} · ${e.id}${terminal}`];
|
|
1671
|
+
const payload = e.payload ?? {};
|
|
1672
|
+
const keys = Object.keys(payload);
|
|
1673
|
+
if (keys.length) {
|
|
1674
|
+
lines.push(` ${keys.map((k) => `${k}=${formatPayloadValue(payload[k])}`).join(" · ")}`);
|
|
1675
|
+
}
|
|
1676
|
+
return lines.join("\n");
|
|
1677
|
+
}
|
|
1678
|
+
/** One nudge-payload value, flattened to a single readable line. */
|
|
1679
|
+
function formatPayloadValue(v) {
|
|
1680
|
+
if (v === null || v === undefined)
|
|
1681
|
+
return "";
|
|
1682
|
+
if (typeof v === "string")
|
|
1683
|
+
return truncate(v, 200);
|
|
1684
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
1685
|
+
return String(v);
|
|
1686
|
+
return truncate(JSON.stringify(v), 200);
|
|
1687
|
+
}
|
|
1688
|
+
const listReviewEvents = defineTool({
|
|
1689
|
+
name: "list_review_events",
|
|
1690
|
+
title: "Drain review events",
|
|
1691
|
+
description: "Non-blocking drain of the next un-acked review nudges for this agent, in FIFO order (strict per review), with " +
|
|
1692
|
+
"the per-review cursors. The durable nudge queue is the authoritative liveness source; webhook/SSE are " +
|
|
1693
|
+
"best-effort fast paths on top of it. Side-effect free: re-calling returns the same frontier until you ack. " +
|
|
1694
|
+
"After acting on an event, call ack_review_event to advance the cursor.\n\n" +
|
|
1695
|
+
"HOW A LOOP TERMINATES: every review that reaches sent / auto_sent / failed / cancelled emits EXACTLY ONE " +
|
|
1696
|
+
"terminal event — `sent`, `send_failed` or `cancelled` — and it is the last and highest-seq event that review " +
|
|
1697
|
+
"will ever produce. Ack it and stop polling that review. `front_run_next` also means stop: you tried to mutate a " +
|
|
1698
|
+
"review somebody already finished.\n\n" +
|
|
1699
|
+
"Non-terminal reasons: `redraft_requested` and `rejected` (redraft via submit_revision), `feedback_added` (a " +
|
|
1700
|
+
"HUMAN commented — answer or redraft), `rule_changed` and `propagate_general_rule` (re-read get_rules, then " +
|
|
1701
|
+
"redraft or restamp_review), `recheck_category` (re-check the category assignment). `staleness` and `approved` " +
|
|
1702
|
+
"are RESERVED and never emitted. Handle any unknown reason by acking and ignoring it — the set grows additively.\n\n" +
|
|
1703
|
+
"Each event's `payload` carries the actionable detail (the delivered message_id, the scrubbed send error, the " +
|
|
1704
|
+
"current revision) and is printed with the event.",
|
|
1705
|
+
inputSchema: {
|
|
1706
|
+
review_id: z.string().optional().describe("Restrict the drain to one review's events (rr_…)."),
|
|
1707
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max events to return."),
|
|
1708
|
+
},
|
|
1709
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1710
|
+
handler: async (args, { client }) => {
|
|
1711
|
+
const res = await client.listReviewEvents({ review_id: args.review_id, limit: args.limit });
|
|
1712
|
+
const text = res.events.length ? res.events.map(renderReviewEvent).join("\n") : "No review events.";
|
|
1713
|
+
return ok(`${res.events.length} review event(s).\n\n${text}`, {
|
|
1714
|
+
events: res.events,
|
|
1715
|
+
cursors: res.cursors ?? [],
|
|
1716
|
+
});
|
|
1717
|
+
},
|
|
1718
|
+
});
|
|
1719
|
+
const waitForReviewEvent = defineTool({
|
|
1720
|
+
name: "wait_for_review_event",
|
|
1721
|
+
title: "Wait for a review event",
|
|
1722
|
+
description: "Long-poll (~25–55s) for the next review nudge: blocks until one is available OR the deadline, then returns like " +
|
|
1723
|
+
"list_review_events (empty on timeout — re-call to keep watching). Use this for an always-on agent that wants to " +
|
|
1724
|
+
"react the instant a human approves/edits/rejects; use list_review_events for a heartbeat drain.",
|
|
1725
|
+
inputSchema: {
|
|
1726
|
+
review_id: z.string().optional().describe("Restrict the wait to one review's events (rr_…)."),
|
|
1727
|
+
wait_seconds: z.number().int().min(1).max(55).optional().describe("Long-poll budget in seconds (default ~30)."),
|
|
1728
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max events to return."),
|
|
1729
|
+
},
|
|
1730
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1731
|
+
handler: async (args, { client }) => {
|
|
1732
|
+
const res = await client.waitForReviewEvent({
|
|
1733
|
+
review_id: args.review_id,
|
|
1734
|
+
wait_seconds: args.wait_seconds,
|
|
1735
|
+
limit: args.limit,
|
|
1736
|
+
});
|
|
1737
|
+
const text = res.events.length ? res.events.map(renderReviewEvent).join("\n") : "No review events (timed out).";
|
|
1738
|
+
return ok(`${res.events.length} review event(s).\n\n${text}`, {
|
|
1739
|
+
events: res.events,
|
|
1740
|
+
cursors: res.cursors ?? [],
|
|
1741
|
+
});
|
|
1742
|
+
},
|
|
1743
|
+
});
|
|
1744
|
+
const ackReviewEvent = defineTool({
|
|
1745
|
+
name: "ack_review_event",
|
|
1746
|
+
title: "Ack review events",
|
|
1747
|
+
description: "Advance the agent's per-review cursor(s) to the supplied through_seq and/or mark broadcast nudges done. Idempotent " +
|
|
1748
|
+
"and monotonic — re-acking an older seq is a no-op (exactly-once effect). Call this AFTER you have acted on the " +
|
|
1749
|
+
"events from list_review_events / wait_for_review_event so the queue does not keep re-surfacing them.",
|
|
1750
|
+
inputSchema: {
|
|
1751
|
+
acks: z
|
|
1752
|
+
.array(z.object({
|
|
1753
|
+
review_id: z.string().describe("The review (rr_…) whose cursor to advance."),
|
|
1754
|
+
through_seq: z.number().int().min(0).describe("Advance the cursor through this seq (inclusive)."),
|
|
1755
|
+
}))
|
|
1756
|
+
.optional()
|
|
1757
|
+
.describe("Per-review cursor advances."),
|
|
1758
|
+
broadcast_ids: z.array(z.string()).optional().describe("Broadcast nudge ids (ndg_…) to mark done."),
|
|
1759
|
+
},
|
|
1760
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1761
|
+
handler: async (args, { client }) => {
|
|
1762
|
+
const res = await client.ackReviewEvent({ acks: args.acks, broadcast_ids: args.broadcast_ids });
|
|
1763
|
+
const cursors = res.cursors ?? [];
|
|
1764
|
+
const text = cursors.length
|
|
1765
|
+
? cursors.map((c) => `• ${c.review_id} → last_acked_seq ${c.last_acked_seq}`).join("\n")
|
|
1766
|
+
: "Acked.";
|
|
1767
|
+
return ok(`Acked review events.\n\n${text}`, { cursors });
|
|
1768
|
+
},
|
|
1769
|
+
});
|
|
1770
|
+
const readMessages = defineTool({
|
|
1771
|
+
name: "read_messages",
|
|
1772
|
+
title: "Read messages",
|
|
1773
|
+
description: "List messages in an inbox, newest first. Narrow with exact-field filters (from/to/subject substring) or " +
|
|
1774
|
+
"unread_only to focus on what's new (native IMAP read state). Page with limit + offset. Returns headers and bodies.",
|
|
1775
|
+
inputSchema: {
|
|
1776
|
+
inbox: inboxRef,
|
|
1777
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max messages to return."),
|
|
1778
|
+
offset: z.number().int().min(0).default(0).describe("Number of messages to skip (paging)."),
|
|
1779
|
+
unread_only: z.boolean().default(false).describe("Only return unread messages (\\Seen flag clear)."),
|
|
1780
|
+
from: z.string().optional().describe("Only messages whose sender contains this substring."),
|
|
1781
|
+
to: z.string().optional().describe("Only messages whose recipient contains this substring."),
|
|
1782
|
+
subject: z.string().optional().describe("Only messages whose subject contains this substring."),
|
|
1783
|
+
},
|
|
1784
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1785
|
+
handler: async (args, { client }) => {
|
|
1786
|
+
const page = await client.listMessages({
|
|
1787
|
+
inbox: args.inbox,
|
|
1788
|
+
limit: args.limit,
|
|
1789
|
+
offset: args.offset,
|
|
1790
|
+
unread_only: args.unread_only,
|
|
1791
|
+
from: args.from,
|
|
1792
|
+
to: args.to,
|
|
1793
|
+
subject: args.subject,
|
|
1794
|
+
});
|
|
1795
|
+
const text = page.items.length
|
|
1796
|
+
? page.items
|
|
1797
|
+
.map((m) => `${renderMessageHeader(m)}\n ${truncate(messagePreview(m), 160)}`)
|
|
1798
|
+
.join("\n\n")
|
|
1799
|
+
: "No messages.";
|
|
1800
|
+
return ok(`${page.items.length} message(s).\n\n${text}`, {
|
|
1801
|
+
items: page.items,
|
|
1802
|
+
total: page.total,
|
|
1803
|
+
});
|
|
1804
|
+
},
|
|
1805
|
+
});
|
|
1806
|
+
const getMessage = defineTool({
|
|
1807
|
+
name: "get_message",
|
|
1808
|
+
title: "Get message",
|
|
1809
|
+
description: "Fetch one message by its opaque id (msg_…), as returned by read_messages / search / wait_for_email. The owning " +
|
|
1810
|
+
"inbox is resolved from the id. Structured output carries nullable source text/HTML fields and their nullable " +
|
|
1811
|
+
"best-effort extracted variants; choose the presentation format below.",
|
|
1812
|
+
inputSchema: {
|
|
1813
|
+
id: z.string().min(1).describe("Opaque message id (msg_…)."),
|
|
1814
|
+
format: z
|
|
1815
|
+
.enum(["auto", "text", "html", "both"])
|
|
1816
|
+
.default("auto")
|
|
1817
|
+
.describe("Presentation format. auto returns an actual text/plain part when present, otherwise the actual HTML part. Missing alternatives are never synthesized."),
|
|
1818
|
+
variant: z
|
|
1819
|
+
.enum(["source", "extracted"])
|
|
1820
|
+
.default("source")
|
|
1821
|
+
.describe("source is authoritative MIME content; extracted is a best-effort quote/signature-stripped derivative."),
|
|
1822
|
+
},
|
|
1823
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1824
|
+
handler: async (args, { client }) => {
|
|
1825
|
+
const m = await client.getMessage(args.id);
|
|
1826
|
+
const body = renderMessageBody(m, args.format, args.variant);
|
|
1827
|
+
return ok(`${renderMessageHeader(m)}\n\n${truncate(body, 12_000)}`, m);
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
const listAttachments = defineTool({
|
|
1831
|
+
name: "list_attachments",
|
|
1832
|
+
title: "List attachments",
|
|
1833
|
+
description: "List the attachments on a message: their opaque id, filename, content type, and size. Use the returned id with " +
|
|
1834
|
+
"get_attachment to download the bytes. The inbox owns the message.",
|
|
1835
|
+
inputSchema: {
|
|
1836
|
+
inbox: inboxRef,
|
|
1837
|
+
message_id: z.string().min(1).describe("Opaque message id (msg_…) whose attachments to list."),
|
|
1838
|
+
},
|
|
1839
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1840
|
+
handler: async (args, { client }) => {
|
|
1841
|
+
const page = await client.listAttachments({
|
|
1842
|
+
inbox: args.inbox,
|
|
1843
|
+
message_id: args.message_id,
|
|
1844
|
+
});
|
|
1845
|
+
const text = page.items.length
|
|
1846
|
+
? page.items
|
|
1847
|
+
.map((a) => `${a.filename} · ${a.content_type} · ${a.size} bytes\n id: ${a.id}`)
|
|
1848
|
+
.join("\n\n")
|
|
1849
|
+
: "No attachments on this message.";
|
|
1850
|
+
return ok(`${page.items.length} attachment(s).\n\n${text}`, {
|
|
1851
|
+
items: page.items,
|
|
1852
|
+
total: page.total,
|
|
1853
|
+
});
|
|
1854
|
+
},
|
|
1855
|
+
});
|
|
1856
|
+
const getAttachment = defineTool({
|
|
1857
|
+
name: "get_attachment",
|
|
1858
|
+
title: "Download an attachment",
|
|
1859
|
+
description: "Download one attachment's bytes (returned base64) by its id (from list_attachments), with its filename and content " +
|
|
1860
|
+
"type. This is the easy attachment fetch: list_attachments to find the id, then get_attachment to pull the file. The " +
|
|
1861
|
+
"inbox owns the message.",
|
|
1862
|
+
inputSchema: {
|
|
1863
|
+
inbox: inboxRef,
|
|
1864
|
+
message_id: z.string().min(1).describe("Opaque message id (msg_…) the attachment belongs to."),
|
|
1865
|
+
attachment_id: z.string().min(1).describe("Opaque attachment id (att_…) from list_attachments."),
|
|
1866
|
+
},
|
|
1867
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1868
|
+
handler: async (args, { client }) => {
|
|
1869
|
+
const att = await client.getAttachment({
|
|
1870
|
+
inbox: args.inbox,
|
|
1871
|
+
message_id: args.message_id,
|
|
1872
|
+
attachment_id: args.attachment_id,
|
|
1873
|
+
});
|
|
1874
|
+
const text = [
|
|
1875
|
+
`Downloaded ${att.filename || "attachment"} (${att.content_type}).`,
|
|
1876
|
+
`Bytes are base64 in structuredContent.content_base64.`,
|
|
1877
|
+
].join("\n");
|
|
1878
|
+
return ok(text, att);
|
|
1879
|
+
},
|
|
1880
|
+
});
|
|
1881
|
+
const markRead = defineTool({
|
|
1882
|
+
name: "mark_read",
|
|
1883
|
+
title: "Mark message read / unread",
|
|
1884
|
+
description: "Set or clear a message's read state via the native IMAP \\Seen flag (Extrovert's label-free read tracking). Pass " +
|
|
1885
|
+
"read=false to mark unread. The message is resolved from its id; inbox is required to open the right inbox.",
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
inbox: inboxRef,
|
|
1888
|
+
id: z.string().min(1).describe("Opaque message id (msg_…)."),
|
|
1889
|
+
read: z.boolean().default(true).describe("true to mark read, false to mark unread."),
|
|
1890
|
+
},
|
|
1891
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1892
|
+
handler: async (args, { client }) => {
|
|
1893
|
+
const m = await client.markRead({ inbox: args.inbox, id: args.id, read: args.read });
|
|
1894
|
+
return ok(`Marked ${m.id} as ${m.seen ? "read" : "unread"}.`, m);
|
|
1895
|
+
},
|
|
1896
|
+
});
|
|
1897
|
+
const listThreads = defineTool({
|
|
1898
|
+
name: "list_threads",
|
|
1899
|
+
title: "List threads",
|
|
1900
|
+
description: "List conversation threads in an inbox, most-recently-active first.",
|
|
1901
|
+
inputSchema: {
|
|
1902
|
+
inbox: inboxRef,
|
|
1903
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max threads to return."),
|
|
1904
|
+
},
|
|
1905
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1906
|
+
handler: async (args, { client }) => {
|
|
1907
|
+
const page = await client.listThreads({ inbox: args.inbox, limit: args.limit });
|
|
1908
|
+
const text = page.items.length ? page.items.map(renderThread).join("\n\n") : "No threads.";
|
|
1909
|
+
return ok(`${page.items.length} thread(s).\n\n${text}`, {
|
|
1910
|
+
items: page.items,
|
|
1911
|
+
total: page.total,
|
|
1912
|
+
});
|
|
1913
|
+
},
|
|
1914
|
+
});
|
|
1915
|
+
const getThread = defineTool({
|
|
1916
|
+
name: "get_thread",
|
|
1917
|
+
title: "Get thread",
|
|
1918
|
+
description: "Fetch one conversation thread by its stable id (thr_…), with all its messages oldest-first. Use this to read a full " +
|
|
1919
|
+
"back-and-forth before replying. The inbox owns the thread.",
|
|
1920
|
+
inputSchema: {
|
|
1921
|
+
inbox: inboxRef,
|
|
1922
|
+
thread_id: z.string().min(1).describe("Stable thread id (thr_…) from list_threads or a message."),
|
|
1923
|
+
},
|
|
1924
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1925
|
+
handler: async (args, { client }) => {
|
|
1926
|
+
const thread = await client.getThread({
|
|
1927
|
+
inbox: args.inbox,
|
|
1928
|
+
thread_id: args.thread_id,
|
|
1929
|
+
});
|
|
1930
|
+
const head = renderThread(thread);
|
|
1931
|
+
const body = thread.messages
|
|
1932
|
+
.map((m) => `${renderMessageHeader(m)}\n ${truncate(messagePreview(m), 200)}`)
|
|
1933
|
+
.join("\n\n");
|
|
1934
|
+
return ok(`${head}\n\n${body}`, thread);
|
|
1935
|
+
},
|
|
1936
|
+
});
|
|
1937
|
+
const deleteMessage = defineTool({
|
|
1938
|
+
name: "delete_message",
|
|
1939
|
+
title: "Delete a message",
|
|
1940
|
+
description: "Delete one message by its opaque id (msg_…). By default it is moved to the Trash folder (a recoverable soft " +
|
|
1941
|
+
"delete); pass expunge=true to permanently remove it. A message already in Trash is always expunged. The inbox " +
|
|
1942
|
+
"owns the message. Returns {id, deleted, expunged, count}.",
|
|
1943
|
+
inputSchema: {
|
|
1944
|
+
inbox: inboxRef.describe("Owned inbox the message belongs to."),
|
|
1945
|
+
id: z.string().min(1).describe("Opaque message id (msg_…)."),
|
|
1946
|
+
expunge: z.boolean().default(false).describe("true to permanently remove instead of moving to Trash."),
|
|
1947
|
+
},
|
|
1948
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1949
|
+
handler: async (args, { client }) => {
|
|
1950
|
+
const result = await client.deleteMessage({ inbox: args.inbox, id: args.id, expunge: args.expunge });
|
|
1951
|
+
const where = result.expunged ? "permanently deleted" : "moved to Trash";
|
|
1952
|
+
return ok(`Message ${result.id} ${where}.`, result);
|
|
1953
|
+
},
|
|
1954
|
+
});
|
|
1955
|
+
const deleteThread = defineTool({
|
|
1956
|
+
name: "delete_thread",
|
|
1957
|
+
title: "Delete a thread",
|
|
1958
|
+
description: "Delete an entire conversation thread by its stable id (thr_…) — every message in it (across INBOX and Sent). By " +
|
|
1959
|
+
"default the messages are moved to Trash (recoverable); pass expunge=true to permanently remove them. The inbox " +
|
|
1960
|
+
"owns the thread. Returns {id, deleted, expunged, count} where count is the number of messages removed.",
|
|
1961
|
+
inputSchema: {
|
|
1962
|
+
inbox: inboxRef.describe("Owned inbox the thread belongs to."),
|
|
1963
|
+
thread_id: z.string().min(1).describe("Stable thread id (thr_…) from list_threads or a message."),
|
|
1964
|
+
expunge: z.boolean().default(false).describe("true to permanently remove instead of moving to Trash."),
|
|
1965
|
+
},
|
|
1966
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1967
|
+
handler: async (args, { client }) => {
|
|
1968
|
+
const result = await client.deleteThread({
|
|
1969
|
+
inbox: args.inbox,
|
|
1970
|
+
thread_id: args.thread_id,
|
|
1971
|
+
expunge: args.expunge,
|
|
1972
|
+
});
|
|
1973
|
+
const where = result.expunged ? "permanently deleted" : "moved to Trash";
|
|
1974
|
+
return ok(`Thread ${result.id} ${where} (${result.count} message(s)).`, result);
|
|
1975
|
+
},
|
|
1976
|
+
});
|
|
1977
|
+
const batchUpdateMessages = defineTool({
|
|
1978
|
+
name: "batch_update_messages",
|
|
1979
|
+
title: "Batch update messages",
|
|
1980
|
+
description: "Mark read/unread and/or move folder for a list of message ids that all belong to one inbox, in a single call. " +
|
|
1981
|
+
"Set read (true=read, false=unread) and/or folder (one of INBOX, Sent, Trash, Junk, Archive) — at least one is " +
|
|
1982
|
+
"required. Ids that are malformed or not owned by the inbox come back in `failed` rather than failing the batch. " +
|
|
1983
|
+
"Returns {updated, failed}.",
|
|
1984
|
+
inputSchema: {
|
|
1985
|
+
inbox: inboxRef.describe("Owned inbox the messages belong to."),
|
|
1986
|
+
ids: z.array(z.string().min(1)).min(1).max(200).describe("Opaque message ids (msg_…), all in this inbox."),
|
|
1987
|
+
read: z.boolean().optional().describe("Set (true) or clear (false) the read flag on each id."),
|
|
1988
|
+
folder: z
|
|
1989
|
+
.enum(["INBOX", "Sent", "Trash", "Junk", "Archive"])
|
|
1990
|
+
.optional()
|
|
1991
|
+
.describe("Move each message to this folder."),
|
|
1992
|
+
},
|
|
1993
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1994
|
+
handler: async (args, { client }) => {
|
|
1995
|
+
if (args.read === undefined && args.folder === undefined) {
|
|
1996
|
+
throw new ExtrovertApiError("Set read and/or folder to update.", 400, "invalid_argument");
|
|
1997
|
+
}
|
|
1998
|
+
const result = await client.batchUpdateMessages({
|
|
1999
|
+
inbox: args.inbox,
|
|
2000
|
+
ids: args.ids,
|
|
2001
|
+
read: args.read,
|
|
2002
|
+
folder: args.folder,
|
|
2003
|
+
});
|
|
2004
|
+
return ok(`Updated ${result.updated.length} message(s); ${result.failed.length} skipped.`, result);
|
|
2005
|
+
},
|
|
2006
|
+
});
|
|
2007
|
+
const search = defineTool({
|
|
2008
|
+
name: "search",
|
|
2009
|
+
title: "Search messages",
|
|
2010
|
+
description: "Full-text search across messages (subject, body, sender). Scope to one inbox with `inbox`, or omit to search all of " +
|
|
2011
|
+
"this agent's inboxes.",
|
|
2012
|
+
inputSchema: {
|
|
2013
|
+
query: z.string().min(1).describe("Search terms."),
|
|
2014
|
+
inbox: inboxRef.optional(),
|
|
2015
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max results."),
|
|
2016
|
+
},
|
|
2017
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2018
|
+
handler: async (args, { client }) => {
|
|
2019
|
+
const page = await client.search({
|
|
2020
|
+
query: args.query,
|
|
2021
|
+
inbox: args.inbox,
|
|
2022
|
+
limit: args.limit,
|
|
2023
|
+
});
|
|
2024
|
+
const text = page.items.length
|
|
2025
|
+
? page.items.map((m) => `${renderMessageHeader(m)}\n ${truncate(messagePreview(m), 160)}`).join("\n\n")
|
|
2026
|
+
: "No matches.";
|
|
2027
|
+
return ok(`${page.items.length} match(es) for "${args.query}".\n\n${text}`, {
|
|
2028
|
+
items: page.items,
|
|
2029
|
+
total: page.total,
|
|
2030
|
+
});
|
|
2031
|
+
},
|
|
2032
|
+
});
|
|
2033
|
+
const waitForEmail = defineTool({
|
|
2034
|
+
name: "wait_for_email",
|
|
2035
|
+
title: "Wait for email (blocking)",
|
|
2036
|
+
description: "Block until the next matching message arrives in an inbox, then return it with any OTP code / verification link " +
|
|
2037
|
+
"already extracted. This is the killer primitive for sign-in and verification flows: trigger the email elsewhere, then " +
|
|
2038
|
+
"call this and act on otp_code / verification_link in the same turn. Narrow the wait with from / subject / regex. " +
|
|
2039
|
+
"Returns matched=false if nothing arrives before timeout — retry or lengthen the timeout if expected.",
|
|
2040
|
+
inputSchema: {
|
|
2041
|
+
inbox: inboxRef,
|
|
2042
|
+
from: z
|
|
2043
|
+
.string()
|
|
2044
|
+
.optional()
|
|
2045
|
+
.describe("Only match senders containing this substring (e.g. 'stripe.com')."),
|
|
2046
|
+
subject: z.string().optional().describe("Only match subjects containing this substring."),
|
|
2047
|
+
regex: z
|
|
2048
|
+
.string()
|
|
2049
|
+
.optional()
|
|
2050
|
+
.describe("Case-sensitive Go RE2 expression over subject or readable body. Prefix with (?i) for case-insensitive matching."),
|
|
2051
|
+
link_hint: z
|
|
2052
|
+
.string()
|
|
2053
|
+
.optional()
|
|
2054
|
+
.describe("Prefer an extracted verification link containing this substring; it does not filter message matches."),
|
|
2055
|
+
since_now: z
|
|
2056
|
+
.boolean()
|
|
2057
|
+
.default(true)
|
|
2058
|
+
.describe("Only match emails that arrive AFTER this call (default). Set false to also match an already-delivered message."),
|
|
2059
|
+
timeout_ms: z
|
|
2060
|
+
.number()
|
|
2061
|
+
.int()
|
|
2062
|
+
.min(1_000)
|
|
2063
|
+
.max(600_000)
|
|
2064
|
+
.default(120_000)
|
|
2065
|
+
.describe("How long to block before giving up, in milliseconds (default 120s)."),
|
|
2066
|
+
},
|
|
2067
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2068
|
+
handler: async (args, { client, config }) => {
|
|
2069
|
+
const timeout = Math.min(args.timeout_ms, config.maxWaitMs);
|
|
2070
|
+
const result = await client.waitForEmail({
|
|
2071
|
+
inbox: args.inbox,
|
|
2072
|
+
from: args.from,
|
|
2073
|
+
subject: args.subject,
|
|
2074
|
+
regex: args.regex,
|
|
2075
|
+
link_hint: args.link_hint,
|
|
2076
|
+
since_now: args.since_now,
|
|
2077
|
+
timeout_ms: timeout,
|
|
2078
|
+
});
|
|
2079
|
+
if (!result.matched || !result.message) {
|
|
2080
|
+
return {
|
|
2081
|
+
content: [
|
|
2082
|
+
{
|
|
2083
|
+
type: "text",
|
|
2084
|
+
text: `No matching email arrived within ${result.waited_ms} ms. Trigger the email and retry, or raise timeout_ms.`,
|
|
2085
|
+
},
|
|
2086
|
+
],
|
|
2087
|
+
structuredContent: result,
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
const m = result.message;
|
|
2091
|
+
const parts = [
|
|
2092
|
+
`Matched after ${result.waited_ms} ms.`,
|
|
2093
|
+
renderMessageHeader(m),
|
|
2094
|
+
"",
|
|
2095
|
+
truncate(messagePreview(m), 600),
|
|
2096
|
+
];
|
|
2097
|
+
if (result.otp_code)
|
|
2098
|
+
parts.push("", `OTP code: ${result.otp_code}`);
|
|
2099
|
+
if (result.verification_link)
|
|
2100
|
+
parts.push(`verification link: ${result.verification_link}`);
|
|
2101
|
+
return ok(parts.join("\n"), result);
|
|
2102
|
+
},
|
|
2103
|
+
});
|
|
2104
|
+
const webhookEventEnum = z
|
|
2105
|
+
.enum(["message.received", "unsubscribe.received"])
|
|
2106
|
+
.describe("A webhook event type delivered by Extrovert. `message.received` fires on inbound mail; " +
|
|
2107
|
+
"`unsubscribe.received` fires when a recipient opts out (one-click List-Unsubscribe or a STOP reply), which is " +
|
|
2108
|
+
"what lets you drop them from your own lists before the next send is refused.");
|
|
2109
|
+
const registerWebhook = defineTool({
|
|
2110
|
+
name: "register_webhook",
|
|
2111
|
+
title: "Register inbound webhook",
|
|
2112
|
+
description: "Register an HTTPS endpoint to receive HMAC-signed inbound-message deliveries. Each delivery carries " +
|
|
2113
|
+
"X-Extrovert-Signature: t=<unix>,v1=<hex hmac-sha256 over \"<t>.<rawbody>\"> — verify it with the signing secret " +
|
|
2114
|
+
"returned ONCE here. Scope to one inbox with `inbox`, or omit to cover every inbox this agent owns. Defaults to " +
|
|
2115
|
+
"the message.received event; subscribe to unsubscribe.received as well to hear about opt-outs.",
|
|
2116
|
+
inputSchema: {
|
|
2117
|
+
url: z.string().url().describe("HTTPS endpoint that receives POSTed deliveries."),
|
|
2118
|
+
events: z
|
|
2119
|
+
.array(webhookEventEnum)
|
|
2120
|
+
.min(1)
|
|
2121
|
+
.optional()
|
|
2122
|
+
.describe("Event types to subscribe to. Defaults to [message.received]."),
|
|
2123
|
+
inbox: inboxRef.optional().describe("Scope to one owned inbox. Omit to cover all of this agent's inboxes."),
|
|
2124
|
+
client_id: z
|
|
2125
|
+
.string()
|
|
2126
|
+
.min(1)
|
|
2127
|
+
.max(128)
|
|
2128
|
+
.optional()
|
|
2129
|
+
.describe("Optional idempotency key. Re-registering with the same client_id replays the original webhook instead of creating a duplicate."),
|
|
2130
|
+
},
|
|
2131
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2132
|
+
handler: async (args, { client }) => {
|
|
2133
|
+
const webhook = await client.registerWebhook({
|
|
2134
|
+
url: args.url,
|
|
2135
|
+
events: args.events,
|
|
2136
|
+
inbox: args.inbox,
|
|
2137
|
+
client_id: args.client_id,
|
|
2138
|
+
});
|
|
2139
|
+
return ok(`Webhook registered.\n${renderWebhook(webhook)}`, webhook);
|
|
2140
|
+
},
|
|
2141
|
+
});
|
|
2142
|
+
const listWebhooks = defineTool({
|
|
2143
|
+
name: "list_webhooks",
|
|
2144
|
+
title: "List webhooks",
|
|
2145
|
+
description: "List this agent's registered inbound webhooks. Signing secrets are redacted (only the prefix is shown).",
|
|
2146
|
+
inputSchema: {},
|
|
2147
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2148
|
+
handler: async (_args, { client }) => {
|
|
2149
|
+
const page = await client.listWebhooks();
|
|
2150
|
+
const text = page.items.length ? page.items.map(renderWebhook).join("\n\n") : "No webhooks registered.";
|
|
2151
|
+
return ok(`${page.items.length} webhook(s).\n\n${text}`, {
|
|
2152
|
+
items: page.items,
|
|
2153
|
+
total: page.total,
|
|
2154
|
+
});
|
|
2155
|
+
},
|
|
2156
|
+
});
|
|
2157
|
+
const getWebhook = defineTool({
|
|
2158
|
+
name: "get_webhook",
|
|
2159
|
+
title: "Get webhook",
|
|
2160
|
+
description: "Fetch one registered webhook by id (whk_…). The signing secret is redacted.",
|
|
2161
|
+
inputSchema: {
|
|
2162
|
+
id: z.string().min(1).describe("Webhook id (whk_…) from register_webhook / list_webhooks."),
|
|
2163
|
+
},
|
|
2164
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2165
|
+
handler: async (args, { client }) => {
|
|
2166
|
+
const webhook = await client.getWebhook(args.id);
|
|
2167
|
+
return ok(renderWebhook(webhook), webhook);
|
|
2168
|
+
},
|
|
2169
|
+
});
|
|
2170
|
+
const updateWebhook = defineTool({
|
|
2171
|
+
name: "update_webhook",
|
|
2172
|
+
title: "Update webhook",
|
|
2173
|
+
description: "Update a registered webhook in place by id (whk_…). Change the delivery `url`, the subscribed `events`, the " +
|
|
2174
|
+
"`inbox` filter (empty string clears it so the webhook covers every inbox this agent owns), or `active` to " +
|
|
2175
|
+
"enable/disable delivery without deleting. Omitted fields are left unchanged. The signing secret is immutable and " +
|
|
2176
|
+
"stays redacted. Returns the updated webhook.",
|
|
2177
|
+
inputSchema: {
|
|
2178
|
+
id: z.string().min(1).describe("Webhook id (whk_…) from register_webhook / list_webhooks."),
|
|
2179
|
+
url: z.string().url().optional().describe("Replace the HTTPS delivery endpoint."),
|
|
2180
|
+
events: z
|
|
2181
|
+
.array(webhookEventEnum)
|
|
2182
|
+
.min(1)
|
|
2183
|
+
.optional()
|
|
2184
|
+
.describe("Replace the subscribed event types."),
|
|
2185
|
+
inbox: z
|
|
2186
|
+
.string()
|
|
2187
|
+
.optional()
|
|
2188
|
+
.describe("Replace the inbox filter. Empty string clears it (covers all of this agent's inboxes)."),
|
|
2189
|
+
active: z.boolean().optional().describe("Enable or disable delivery without deleting the webhook."),
|
|
2190
|
+
},
|
|
2191
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2192
|
+
handler: async (args, { client }) => {
|
|
2193
|
+
const webhook = await client.updateWebhook(args.id, {
|
|
2194
|
+
url: args.url,
|
|
2195
|
+
events: args.events,
|
|
2196
|
+
inbox: args.inbox,
|
|
2197
|
+
active: args.active,
|
|
2198
|
+
});
|
|
2199
|
+
return ok(`Webhook updated.\n${renderWebhook(webhook)}`, webhook);
|
|
2200
|
+
},
|
|
2201
|
+
});
|
|
2202
|
+
const deleteWebhook = defineTool({
|
|
2203
|
+
name: "delete_webhook",
|
|
2204
|
+
title: "Delete webhook",
|
|
2205
|
+
description: "Delete a registered webhook by id (whk_…). Deliveries stop immediately. This cannot be undone.",
|
|
2206
|
+
inputSchema: {
|
|
2207
|
+
id: z.string().min(1).describe("Webhook id (whk_…) to delete."),
|
|
2208
|
+
},
|
|
2209
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
2210
|
+
handler: async (args, { client }) => {
|
|
2211
|
+
const result = await client.deleteWebhook(args.id);
|
|
2212
|
+
return ok(`Deleted webhook ${result.id}.`, result);
|
|
2213
|
+
},
|
|
2214
|
+
});
|
|
2215
|
+
const addContactListEntry = defineTool({
|
|
2216
|
+
name: "add_contact_list_entry",
|
|
2217
|
+
title: "Add a contact allow/block entry",
|
|
2218
|
+
description: "Add an allow or block entry to an inbox's contact lists. A `block` entry rejects a send to a matching " +
|
|
2219
|
+
"recipient; once any `allow` entry exists for an inbox, sends from it are restricted to recipients that match " +
|
|
2220
|
+
"one of them (allowlist mode). `pattern` is a bare email address (matched in full) or a bare domain " +
|
|
2221
|
+
"(matches any address in that domain). Returns the created entry (addressable by its opaque id for delete).",
|
|
2222
|
+
inputSchema: {
|
|
2223
|
+
inbox: inboxRef.describe("Owned inbox to attach the entry to (its sends are governed)."),
|
|
2224
|
+
kind: z.enum(["allow", "block"]).describe("allow = permit; block = reject a matching recipient."),
|
|
2225
|
+
direction: z
|
|
2226
|
+
.enum(["send", "receive"])
|
|
2227
|
+
.optional()
|
|
2228
|
+
.describe("Traffic direction. Defaults to send (only send is enforced today)."),
|
|
2229
|
+
pattern: z.string().min(1).describe("A bare email address (bob@acme.com) or a bare domain (acme.com)."),
|
|
2230
|
+
},
|
|
2231
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2232
|
+
handler: async (args, { client }) => {
|
|
2233
|
+
const entry = await client.addContactListEntry({
|
|
2234
|
+
inbox: args.inbox,
|
|
2235
|
+
kind: args.kind,
|
|
2236
|
+
direction: args.direction,
|
|
2237
|
+
pattern: args.pattern,
|
|
2238
|
+
});
|
|
2239
|
+
return ok(`Contact list entry added.\n${renderContactListEntry(entry)}`, entry);
|
|
2240
|
+
},
|
|
2241
|
+
});
|
|
2242
|
+
const listContactListEntries = defineTool({
|
|
2243
|
+
name: "list_contact_lists",
|
|
2244
|
+
title: "List contact allow/block entries",
|
|
2245
|
+
description: "List the allow/block contact-list entries that govern an inbox: its inbox-specific entries plus any " +
|
|
2246
|
+
"account-wide entries that apply to every inbox this agent owns.",
|
|
2247
|
+
inputSchema: {
|
|
2248
|
+
inbox: inboxRef.describe("Owned inbox whose governing entries to list."),
|
|
2249
|
+
},
|
|
2250
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2251
|
+
handler: async (args, { client }) => {
|
|
2252
|
+
const page = await client.listContactListEntries(args.inbox);
|
|
2253
|
+
const text = page.items.length
|
|
2254
|
+
? page.items.map(renderContactListEntry).join("\n\n")
|
|
2255
|
+
: "No contact-list entries for this inbox.";
|
|
2256
|
+
return ok(`${page.items.length} entry(ies).\n\n${text}`, {
|
|
2257
|
+
items: page.items,
|
|
2258
|
+
total: page.total,
|
|
2259
|
+
});
|
|
2260
|
+
},
|
|
2261
|
+
});
|
|
2262
|
+
const deleteContactListEntry = defineTool({
|
|
2263
|
+
name: "delete_contact_list_entry",
|
|
2264
|
+
title: "Delete a contact allow/block entry",
|
|
2265
|
+
description: "Delete a contact-list entry by id (lst_…). The allow/block rule stops applying immediately.",
|
|
2266
|
+
inputSchema: {
|
|
2267
|
+
inbox: inboxRef.describe("Owned inbox the entry belongs to."),
|
|
2268
|
+
id: z.string().min(1).describe("Entry id (lst_…) from add_contact_list_entry / list_contact_lists."),
|
|
2269
|
+
},
|
|
2270
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
2271
|
+
handler: async (args, { client }) => {
|
|
2272
|
+
const result = await client.deleteContactListEntry(args.inbox, args.id);
|
|
2273
|
+
return ok(`Deleted contact list entry ${result.id}.`, result);
|
|
2274
|
+
},
|
|
2275
|
+
});
|
|
2276
|
+
// ---------------------------------------------------------------------------
|
|
2277
|
+
// Suppressions (recipient opt-outs / list-unsubscribe)
|
|
2278
|
+
// ---------------------------------------------------------------------------
|
|
2279
|
+
const checkSuppression = defineTool({
|
|
2280
|
+
name: "check_suppression",
|
|
2281
|
+
title: "Check whether a recipient has opted out",
|
|
2282
|
+
description: "Pre-check, BEFORE you compose, whether a recipient has opted out of this org's mail (list-unsubscribe / " +
|
|
2283
|
+
"suppression). If suppressed=true, a send to that address WILL be rejected with recipient_suppressed — do NOT " +
|
|
2284
|
+
"include them; drop that recipient or pick another. This checks your OWN org's opt-outs only (it never reveals " +
|
|
2285
|
+
"a platform-wide or other-tenant opt-out). Returns suppressed (bool) plus the matching org rows (each with an id " +
|
|
2286
|
+
"you can revoke_suppression if the recipient asked to resume).",
|
|
2287
|
+
inputSchema: {
|
|
2288
|
+
recipient: emailAddress.describe("The recipient address to pre-check for an opt-out."),
|
|
2289
|
+
},
|
|
2290
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2291
|
+
handler: async (args, { client }) => {
|
|
2292
|
+
const res = await client.precheckSuppression(args.recipient);
|
|
2293
|
+
const head = res.suppressed
|
|
2294
|
+
? `SUPPRESSED — do NOT send to ${res.recipient} (it will be rejected with recipient_suppressed).`
|
|
2295
|
+
: `Not suppressed — ${res.recipient} may be mailed.`;
|
|
2296
|
+
const rows = res.rows.length ? "\n\n" + res.rows.map(renderSuppression).join("\n\n") : "";
|
|
2297
|
+
return ok(`${head}${rows}`, {
|
|
2298
|
+
recipient: res.recipient,
|
|
2299
|
+
suppressed: res.suppressed,
|
|
2300
|
+
rows: res.rows,
|
|
2301
|
+
});
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
const listSuppressions = defineTool({
|
|
2305
|
+
name: "list_suppressions",
|
|
2306
|
+
title: "List recipient opt-outs (suppressions)",
|
|
2307
|
+
description: "List this org's recipient opt-outs (list-unsubscribe / suppression rows), newest first. These are recipients " +
|
|
2308
|
+
"the org may no longer email — a send to one is rejected with recipient_suppressed. Active rows only by default; " +
|
|
2309
|
+
"pass include_revoked=true to also see revoked rows. Only your OWN org's rows are returned (never platform-wide " +
|
|
2310
|
+
"or other-tenant opt-outs). Use check_suppression to test one specific address instead.",
|
|
2311
|
+
inputSchema: {
|
|
2312
|
+
include_revoked: z
|
|
2313
|
+
.boolean()
|
|
2314
|
+
.default(false)
|
|
2315
|
+
.describe("Also include revoked rows (default false = active opt-outs only)."),
|
|
2316
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max rows to return."),
|
|
2317
|
+
cursor: z.string().optional().describe("Opaque cursor from a previous call's next_cursor."),
|
|
2318
|
+
},
|
|
2319
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2320
|
+
handler: async (args, { client }) => {
|
|
2321
|
+
const page = await client.listSuppressions({
|
|
2322
|
+
include_revoked: args.include_revoked,
|
|
2323
|
+
limit: args.limit,
|
|
2324
|
+
cursor: args.cursor,
|
|
2325
|
+
});
|
|
2326
|
+
const text = page.items.length
|
|
2327
|
+
? page.items.map(renderSuppression).join("\n\n")
|
|
2328
|
+
: "No suppressions.";
|
|
2329
|
+
return ok(`${page.items.length} suppression(s).\n\n${text}`, {
|
|
2330
|
+
items: page.items,
|
|
2331
|
+
total: page.total,
|
|
2332
|
+
next_cursor: page.next_cursor,
|
|
2333
|
+
});
|
|
2334
|
+
},
|
|
2335
|
+
});
|
|
2336
|
+
const revokeSuppression = defineTool({
|
|
2337
|
+
name: "revoke_suppression",
|
|
2338
|
+
title: "Revoke a suppression (re-enable a recipient)",
|
|
2339
|
+
description: "Revoke ONE of this org's suppression rows so the recipient can be emailed again — e.g. the recipient explicitly " +
|
|
2340
|
+
"asked to resubscribe. A reason is REQUIRED and is audit-logged (do not revoke without a genuine recipient signal: " +
|
|
2341
|
+
"re-suppressing after a revoke flags the org for abuse review). You may only revoke your OWN org's rows (a foreign, " +
|
|
2342
|
+
"platform-global, or shared-domain id is an indistinguishable not-found). Find the id via list_suppressions or " +
|
|
2343
|
+
"check_suppression. Returns the revoked row.",
|
|
2344
|
+
inputSchema: {
|
|
2345
|
+
id: z.string().min(1).describe("Suppression row id (sup_…) from list_suppressions / check_suppression."),
|
|
2346
|
+
reason: z
|
|
2347
|
+
.string()
|
|
2348
|
+
.min(1)
|
|
2349
|
+
.describe("Why the opt-out is being revoked (required, audit-logged). E.g. 'recipient re-subscribed via reply'."),
|
|
2350
|
+
},
|
|
2351
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2352
|
+
handler: async (args, { client }) => {
|
|
2353
|
+
if (!args.reason.trim()) {
|
|
2354
|
+
throw new ExtrovertApiError("A reason is required to revoke a suppression.", 400, "invalid_argument");
|
|
2355
|
+
}
|
|
2356
|
+
const row = await client.revokeSuppression(args.id, args.reason);
|
|
2357
|
+
return ok(`Revoked — ${row.recipient} may be emailed again.\n${renderSuppression(row)}`, row);
|
|
2358
|
+
},
|
|
2359
|
+
});
|
|
2360
|
+
// ---------------------------------------------------------------------------
|
|
2361
|
+
// Reputation / deliverability (diverse-smtp M7) — read-only, org-scoped
|
|
2362
|
+
// ---------------------------------------------------------------------------
|
|
2363
|
+
const getDeliverabilityStatus = defineTool({
|
|
2364
|
+
name: "get_deliverability_status",
|
|
2365
|
+
title: "Get deliverability status",
|
|
2366
|
+
description: "Read this org's outbound deliverability health: an overall status badge (healthy / at_risk / paused / enforced / " +
|
|
2367
|
+
"unknown), each sending provider/tenant's status, the latest window's Sends/Bounces/Complaints (with rates), and " +
|
|
2368
|
+
"the count of open findings. Use it before a large send to check the org isn't paused or at risk. Read-only, " +
|
|
2369
|
+
"org-scoped — there is no pause/unpause control here. Advisor findings show 'unavailable_vdm_disabled' when the " +
|
|
2370
|
+
"provider's deliverability manager is off (status and metrics are still shown).",
|
|
2371
|
+
inputSchema: {},
|
|
2372
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2373
|
+
handler: async (_args, { client }) => {
|
|
2374
|
+
const rep = await client.getReputation();
|
|
2375
|
+
const m = rep.metrics;
|
|
2376
|
+
const lines = [
|
|
2377
|
+
`status: ${rep.status} · sending: ${rep.sending_status} · open findings: ${rep.open_findings}`,
|
|
2378
|
+
`metrics: ${m.sends} sends · ${m.bounces} bounces (${(m.bounce_rate * 100).toFixed(2)}%) · ${m.complaints} complaints (${(m.complaint_rate * 100).toFixed(3)}%)`,
|
|
2379
|
+
...rep.providers.map((p) => `- ${p.provider} ${p.label ?? p.provider_account_id}: ${p.sending_status}` +
|
|
2380
|
+
` · advisor: ${p.advisor_findings_status}`),
|
|
2381
|
+
];
|
|
2382
|
+
return ok(lines.join("\n"), rep);
|
|
2383
|
+
},
|
|
2384
|
+
});
|
|
2385
|
+
const listDeliverabilityFindings = defineTool({
|
|
2386
|
+
name: "list_deliverability_findings",
|
|
2387
|
+
title: "List deliverability findings",
|
|
2388
|
+
description: "List this org's deliverability findings (bounce/complaint/auth/blocklist issues affecting sending), newest first. " +
|
|
2389
|
+
"Filter by status (open/resolved), severity (low/high), domain, or sender. Read-only, org-scoped. When the provider's " +
|
|
2390
|
+
"deliverability manager (VDM) is off, advisor findings are unavailable and this returns an empty list — use " +
|
|
2391
|
+
"get_deliverability_status for the always-available status and metrics.",
|
|
2392
|
+
inputSchema: {
|
|
2393
|
+
status: z.enum(["open", "resolved"]).optional().describe("Filter by finding status."),
|
|
2394
|
+
severity: z.enum(["low", "high", "unknown"]).optional().describe("Filter by severity."),
|
|
2395
|
+
domain: z.string().optional().describe("Filter to one sending domain."),
|
|
2396
|
+
sender: z.string().optional().describe("Filter to one sender address."),
|
|
2397
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max rows to return."),
|
|
2398
|
+
cursor: z.string().optional().describe("Opaque cursor from a previous call's next_cursor."),
|
|
2399
|
+
},
|
|
2400
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2401
|
+
handler: async (args, { client }) => {
|
|
2402
|
+
const page = await client.listDeliverabilityFindings({
|
|
2403
|
+
status: args.status,
|
|
2404
|
+
severity: args.severity,
|
|
2405
|
+
domain: args.domain,
|
|
2406
|
+
sender: args.sender,
|
|
2407
|
+
limit: args.limit,
|
|
2408
|
+
cursor: args.cursor,
|
|
2409
|
+
});
|
|
2410
|
+
const text = page.items.length
|
|
2411
|
+
? page.items
|
|
2412
|
+
.map((f) => `[${f.severity}/${f.status}] ${f.type}${f.domain ? ` (${f.domain})` : ""}: ${f.title} — ${f.detail}`)
|
|
2413
|
+
.join("\n")
|
|
2414
|
+
: "No findings.";
|
|
2415
|
+
return ok(`${page.items.length} finding(s).\n\n${text}`, {
|
|
2416
|
+
items: page.items,
|
|
2417
|
+
total: page.total,
|
|
2418
|
+
next_cursor: page.next_cursor,
|
|
2419
|
+
});
|
|
2420
|
+
},
|
|
2421
|
+
});
|
|
2422
|
+
// ---------------------------------------------------------------------------
|
|
2423
|
+
// Domains (Slice 5) — privileged (domain:manage scope)
|
|
2424
|
+
// ---------------------------------------------------------------------------
|
|
2425
|
+
const listDomains = defineTool({
|
|
2426
|
+
name: "list_domains",
|
|
2427
|
+
title: "List the customer's domains",
|
|
2428
|
+
description: "List the domains onboarded for this agent's customer, each with its onboarding mode and " +
|
|
2429
|
+
"verification/DKIM status. Domain management is privileged — the agent key must carry the " +
|
|
2430
|
+
"domain:manage scope or these tools return a 403.",
|
|
2431
|
+
inputSchema: {},
|
|
2432
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2433
|
+
handler: async (_args, { client }) => {
|
|
2434
|
+
const page = await client.listDomains();
|
|
2435
|
+
const text = page.items.length ? page.items.map(renderDomain).join("\n\n") : "No domains onboarded.";
|
|
2436
|
+
return ok(`${page.items.length} domain(s).\n\n${text}`, { items: page.items, total: page.total });
|
|
2437
|
+
},
|
|
2438
|
+
});
|
|
2439
|
+
const getDomain = defineTool({
|
|
2440
|
+
name: "get_domain",
|
|
2441
|
+
title: "Get a domain's status and DNS records",
|
|
2442
|
+
description: "Get one domain's verification status plus the DNS records the customer must set, inline. For " +
|
|
2443
|
+
"ns_delegated domains this also returns the single NS delegation to add at the registrar.",
|
|
2444
|
+
inputSchema: {
|
|
2445
|
+
domain: z.string().min(1).describe("The fully-qualified domain name (e.g. mail.acme.com)."),
|
|
2446
|
+
},
|
|
2447
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2448
|
+
handler: async (args, { client }) => {
|
|
2449
|
+
const domain = await client.getDomain(args.domain);
|
|
2450
|
+
return ok(renderDomain(domain), domain);
|
|
2451
|
+
},
|
|
2452
|
+
});
|
|
2453
|
+
const onboardDomain = defineTool({
|
|
2454
|
+
name: "onboard_domain",
|
|
2455
|
+
title: "Onboard (add) a domain",
|
|
2456
|
+
description: "Onboard a domain for the customer. `mode` selects the path: `shared` (instant, no DNS), " +
|
|
2457
|
+
"`ns_delegated` (default; delegate one NS record and we serve the zone + rotate the signing keys), `manual` " +
|
|
2458
|
+
"(returns the record set to add yourself), or `purchased` (buy-through-us; runs as a money-safe " +
|
|
2459
|
+
"background job). All modes require the domain:manage scope; `purchased` ADDITIONALLY requires the explicit, " +
|
|
2460
|
+
"default-off domain:purchase scope (because it spends money at the registrar) and is bounded by the org/project " +
|
|
2461
|
+
"purchased-domain cap — check whoami's scopes before requesting it. Use `scope` to make the domain org-shared " +
|
|
2462
|
+
"(default) or bind it to this key's fixed project. Returns the record set / NS instruction.",
|
|
2463
|
+
inputSchema: {
|
|
2464
|
+
domain: z.string().min(1).describe("The domain to onboard (e.g. mail.acme.com)."),
|
|
2465
|
+
mode: z
|
|
2466
|
+
.enum(["shared", "ns_delegated", "manual", "purchased"])
|
|
2467
|
+
.optional()
|
|
2468
|
+
.describe("Onboarding path. Defaults to ns_delegated. `purchased` additionally requires the domain:purchase scope (opt-in)."),
|
|
2469
|
+
mail_host_ip: z
|
|
2470
|
+
.string()
|
|
2471
|
+
.optional()
|
|
2472
|
+
.describe("A-record IP served at a delegated zone's apex (ns_delegated only)."),
|
|
2473
|
+
scope: z
|
|
2474
|
+
.enum(["org", "project"])
|
|
2475
|
+
.optional()
|
|
2476
|
+
.describe("Domain visibility. `org` (default) lets every project in the org use it; `project` binds it to this key's " +
|
|
2477
|
+
"fixed project only (never client-selected — derived from the key)."),
|
|
2478
|
+
project_id: projectAssertion,
|
|
2479
|
+
},
|
|
2480
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2481
|
+
handler: async (args, { client }) => {
|
|
2482
|
+
const domain = await client.onboardDomain({
|
|
2483
|
+
domain: args.domain,
|
|
2484
|
+
mode: args.mode,
|
|
2485
|
+
mail_host_ip: args.mail_host_ip,
|
|
2486
|
+
scope: args.scope,
|
|
2487
|
+
project_id: args.project_id,
|
|
2488
|
+
});
|
|
2489
|
+
return ok(`Domain onboarded.\n${renderDomain(domain)}`, domain);
|
|
2490
|
+
},
|
|
2491
|
+
});
|
|
2492
|
+
const verifyDomain = defineTool({
|
|
2493
|
+
name: "verify_domain",
|
|
2494
|
+
title: "Trigger or refresh domain verification",
|
|
2495
|
+
description: "Trigger/refresh verification for an onboarded domain and return its (possibly advanced) status. " +
|
|
2496
|
+
"For purchased domains this re-drives the resumable buy/verify pipeline; for manual/ns_delegated " +
|
|
2497
|
+
"it re-reads status and returns the records still to set. Idempotent.",
|
|
2498
|
+
inputSchema: {
|
|
2499
|
+
domain: z.string().min(1).describe("The domain to (re)verify."),
|
|
2500
|
+
},
|
|
2501
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2502
|
+
handler: async (args, { client }) => {
|
|
2503
|
+
const domain = await client.verifyDomain(args.domain);
|
|
2504
|
+
return ok(`Verification refreshed.\n${renderDomain(domain)}`, domain);
|
|
2505
|
+
},
|
|
2506
|
+
});
|
|
2507
|
+
const offboardDomain = defineTool({
|
|
2508
|
+
name: "offboard_domain",
|
|
2509
|
+
title: "Offboard (remove) a domain",
|
|
2510
|
+
description: "Remove a domain from the customer. DESTRUCTIVE AND IRREVERSIBLE: the teardown job's FIRST step " +
|
|
2511
|
+
"cascade-deletes EVERY inbox on that domain — the mailbox itself, its stored messages and its " +
|
|
2512
|
+
"sender identity — and only then reaps the outbound provider senders + routing and scrubs the DNS " +
|
|
2513
|
+
"zone and the domain record. Nothing on the domain survives; there is no undo. Move or export " +
|
|
2514
|
+
"anything you need before calling this. Runs as an async job: this ACCEPTS the request and returns " +
|
|
2515
|
+
"a job id + poll URL (status_url). Poll the returned job_id with get_job until its status is " +
|
|
2516
|
+
"terminal (succeeded/failed/cancelled) to confirm teardown finished. Requires the domain:manage " +
|
|
2517
|
+
"scope.",
|
|
2518
|
+
inputSchema: {
|
|
2519
|
+
domain: z.string().min(1).describe("The domain to offboard."),
|
|
2520
|
+
},
|
|
2521
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
2522
|
+
handler: async (args, { client }) => {
|
|
2523
|
+
const result = await client.offboardDomain(args.domain);
|
|
2524
|
+
return ok(`Offboard accepted for ${result.domain} (status: ${result.status}). Poll ${result.status_url} (get_job with job_id "${result.job_id}") until it is terminal (succeeded/failed/cancelled).`, result);
|
|
2525
|
+
},
|
|
2526
|
+
});
|
|
2527
|
+
const getJob = defineTool({
|
|
2528
|
+
name: "get_job",
|
|
2529
|
+
title: "Poll an async job's status",
|
|
2530
|
+
description: "Poll an async job like the offboard_domain teardown until its status is terminal " +
|
|
2531
|
+
"(succeeded/failed/cancelled). Pass the job_id returned by the tool that started the job " +
|
|
2532
|
+
"(e.g. offboard_domain's job_id / status_url). An unknown or foreign job id is a 404.",
|
|
2533
|
+
inputSchema: {
|
|
2534
|
+
job_id: z.string().min(1).describe("The job id to poll (e.g. from offboard_domain's job_id)."),
|
|
2535
|
+
},
|
|
2536
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2537
|
+
handler: async (args, { client }) => {
|
|
2538
|
+
const job = await client.getJob(args.job_id);
|
|
2539
|
+
return ok(renderJob(job), job);
|
|
2540
|
+
},
|
|
2541
|
+
});
|
|
2542
|
+
const streamInfo = defineTool({
|
|
2543
|
+
name: "stream_info",
|
|
2544
|
+
title: "Real-time stream (SSE) info",
|
|
2545
|
+
description: "Describe the real-time Server-Sent-Events (SSE) endpoints for watching inboxes live. MCP is request/response and " +
|
|
2546
|
+
"cannot hold an open stream, so this tool does NOT stream — it returns the endpoint URLs + how to consume them " +
|
|
2547
|
+
"directly (curl / EventSource / the @extrovert.dev/sdk `inbox.stream()` / `extrovert.stream()` helper). Each SSE event " +
|
|
2548
|
+
"carries a monotonic `id:` (the resume token); reconnect with the `Last-Event-ID` header (or `?last_event_id=`) to " +
|
|
2549
|
+
"replay everything after it. Events use the same envelope a webhook delivers (e.g. message.received). To get pushed " +
|
|
2550
|
+
"deliveries inside an MCP-only setup, use register_webhook instead.",
|
|
2551
|
+
inputSchema: {
|
|
2552
|
+
inbox: inboxRef.optional().describe("Scope the stream to one owned inbox. Omit for the all-inboxes stream."),
|
|
2553
|
+
},
|
|
2554
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2555
|
+
handler: async (args, { config }) => {
|
|
2556
|
+
const base = config.apiBaseUrl.replace(/\/+$/, "");
|
|
2557
|
+
const inboxPath = args.inbox
|
|
2558
|
+
? `/v1/inboxes/${encodeURIComponent(args.inbox)}/stream`
|
|
2559
|
+
: undefined;
|
|
2560
|
+
const allPath = "/v1/events";
|
|
2561
|
+
const url = (path) => `${base}${path}`;
|
|
2562
|
+
const target = inboxPath ?? allPath;
|
|
2563
|
+
const lines = [
|
|
2564
|
+
"Extrovert exposes a real-time SSE stream. MCP cannot hold the connection open — consume it directly:",
|
|
2565
|
+
"",
|
|
2566
|
+
args.inbox
|
|
2567
|
+
? `• One inbox: GET ${url(inboxPath)}`
|
|
2568
|
+
: `• All inboxes: GET ${url(allPath)}`,
|
|
2569
|
+
args.inbox ? `• All inboxes: GET ${url(allPath)}` : `• One inbox: GET ${url("/v1/inboxes/{address}/stream")}`,
|
|
2570
|
+
"",
|
|
2571
|
+
"Headers: Authorization: Bearer <agent key>, Accept: text/event-stream.",
|
|
2572
|
+
"Resume: set Last-Event-ID: <last seq> (or ?last_event_id=<seq>) to replay events after it.",
|
|
2573
|
+
"Events: same envelope as webhooks (e.g. message.received) in each frame's data: line.",
|
|
2574
|
+
"",
|
|
2575
|
+
`curl: curl -N -H "Authorization: Bearer $EXTROVERT_API_KEY" ${url(target)}`,
|
|
2576
|
+
"SDK: for await (const ev of inbox.stream()) { /* ev.event, ev.seq, ev.message */ }",
|
|
2577
|
+
" (or extrovert.stream() for all inboxes; pass { lastEventId } to resume)",
|
|
2578
|
+
];
|
|
2579
|
+
return ok(lines.join("\n"), {
|
|
2580
|
+
stream_url: url(target),
|
|
2581
|
+
all_inboxes_url: url(allPath),
|
|
2582
|
+
inbox_url: inboxPath ? url(inboxPath) : null,
|
|
2583
|
+
resume_header: "Last-Event-ID",
|
|
2584
|
+
resume_query_param: "last_event_id",
|
|
2585
|
+
content_type: "text/event-stream",
|
|
2586
|
+
mcp_can_stream: false,
|
|
2587
|
+
});
|
|
2588
|
+
},
|
|
2589
|
+
});
|
|
2590
|
+
// ---------------------------------------------------------------------------
|
|
2591
|
+
// Registration
|
|
2592
|
+
// ---------------------------------------------------------------------------
|
|
2593
|
+
const ALL_TOOLS = [
|
|
2594
|
+
redeemEnrollment,
|
|
2595
|
+
signUp,
|
|
2596
|
+
verifySignup,
|
|
2597
|
+
whoami,
|
|
2598
|
+
createInbox,
|
|
2599
|
+
listInboxes,
|
|
2600
|
+
getInbox,
|
|
2601
|
+
updateInbox,
|
|
2602
|
+
exportEmailConfig,
|
|
2603
|
+
deleteInbox,
|
|
2604
|
+
sendEmail,
|
|
2605
|
+
replyEmail,
|
|
2606
|
+
forwardEmail,
|
|
2607
|
+
listReviews,
|
|
2608
|
+
getReview,
|
|
2609
|
+
getReviewTurns,
|
|
2610
|
+
getReviewFeedback,
|
|
2611
|
+
getReviewDecisionContext,
|
|
2612
|
+
reviewerDecide,
|
|
2613
|
+
postReviewChat,
|
|
2614
|
+
submitRevision,
|
|
2615
|
+
cancelReview,
|
|
2616
|
+
restampReview,
|
|
2617
|
+
listCategories,
|
|
2618
|
+
getCategory,
|
|
2619
|
+
proposeCategory,
|
|
2620
|
+
updateCategory,
|
|
2621
|
+
getRiskDial,
|
|
2622
|
+
getGraduationStatus,
|
|
2623
|
+
getBacklogStatus,
|
|
2624
|
+
getPacingState,
|
|
2625
|
+
proposeGraduation,
|
|
2626
|
+
getRules,
|
|
2627
|
+
saveRule,
|
|
2628
|
+
promoteRule,
|
|
2629
|
+
retireRule,
|
|
2630
|
+
getRuleAudit,
|
|
2631
|
+
undoRuleChange,
|
|
2632
|
+
listReviewEvents,
|
|
2633
|
+
waitForReviewEvent,
|
|
2634
|
+
ackReviewEvent,
|
|
2635
|
+
readMessages,
|
|
2636
|
+
getMessage,
|
|
2637
|
+
listAttachments,
|
|
2638
|
+
getAttachment,
|
|
2639
|
+
markRead,
|
|
2640
|
+
listThreads,
|
|
2641
|
+
getThread,
|
|
2642
|
+
deleteMessage,
|
|
2643
|
+
deleteThread,
|
|
2644
|
+
batchUpdateMessages,
|
|
2645
|
+
search,
|
|
2646
|
+
waitForEmail,
|
|
2647
|
+
registerWebhook,
|
|
2648
|
+
listWebhooks,
|
|
2649
|
+
getWebhook,
|
|
2650
|
+
updateWebhook,
|
|
2651
|
+
deleteWebhook,
|
|
2652
|
+
addContactListEntry,
|
|
2653
|
+
listContactListEntries,
|
|
2654
|
+
deleteContactListEntry,
|
|
2655
|
+
checkSuppression,
|
|
2656
|
+
listSuppressions,
|
|
2657
|
+
revokeSuppression,
|
|
2658
|
+
getDeliverabilityStatus,
|
|
2659
|
+
listDeliverabilityFindings,
|
|
2660
|
+
listDomains,
|
|
2661
|
+
getDomain,
|
|
2662
|
+
onboardDomain,
|
|
2663
|
+
verifyDomain,
|
|
2664
|
+
offboardDomain,
|
|
2665
|
+
getJob,
|
|
2666
|
+
streamInfo,
|
|
2667
|
+
];
|
|
2668
|
+
/** The set of tool names this server exposes (handy for tests/docs). */
|
|
2669
|
+
export const TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
|
2670
|
+
/** Register every Extrovert tool onto an MCP server instance. */
|
|
2671
|
+
export function registerTools(server, ctx) {
|
|
2672
|
+
for (const tool of ALL_TOOLS) {
|
|
2673
|
+
tool.register(server, ctx);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
// ---------------------------------------------------------------------------
|
|
2677
|
+
// helpers
|
|
2678
|
+
// ---------------------------------------------------------------------------
|
|
2679
|
+
function truncate(text, max) {
|
|
2680
|
+
const trimmed = text.trim();
|
|
2681
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}…`;
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* Map an offline-store sentinel error (by class name, to avoid importing the
|
|
2685
|
+
* fixtures module) to the contract's problem-code + HTTP status (redesign §5.1
|
|
2686
|
+
* closed enum). The live path already carries these via {@link ExtrovertApiError};
|
|
2687
|
+
* this keeps the OFFLINE error surface identical so an agent switching between
|
|
2688
|
+
* mock and live sees the same machine codes.
|
|
2689
|
+
*/
|
|
2690
|
+
const MOCK_ERROR_MAP = {
|
|
2691
|
+
NotFoundError: { status: 404, code: "not_found" },
|
|
2692
|
+
ForbiddenError: { status: 403, code: "forbidden_scope" },
|
|
2693
|
+
BreadthRequiredError: { status: 400, code: "breadth_required" },
|
|
2694
|
+
BlockedError: { status: 403, code: "recipient_blocked" },
|
|
2695
|
+
SuppressedError: { status: 422, code: "recipient_suppressed" },
|
|
2696
|
+
// `intent_required`, NOT `bad_request`: it is its own closed code with its own
|
|
2697
|
+
// remediation, and an agent that saw the generic code had no way to tell "add an
|
|
2698
|
+
// intent and retry" from "your request was malformed".
|
|
2699
|
+
IntentRequiredError: { status: 422, code: "intent_required" },
|
|
2700
|
+
ConflictingAliasError: { status: 400, code: "bad_request" },
|
|
2701
|
+
// The 409 SPLIT. `stale` is the only one of these worth retrying; `wrong_state`
|
|
2702
|
+
// needs a different verb and `terminal` needs the agent to STOP. Collapsing them
|
|
2703
|
+
// into one `conflict` is what let a single 409 handler retry a sent message
|
|
2704
|
+
// forever, so the mock must distinguish them exactly as the live API does.
|
|
2705
|
+
StaleError: { status: 409, code: "stale" },
|
|
2706
|
+
WrongStateError: { status: 409, code: "wrong_state" },
|
|
2707
|
+
TerminalError: { status: 409, code: "terminal" },
|
|
2708
|
+
ConflictError: { status: 409, code: "conflict" },
|
|
2709
|
+
};
|
|
2710
|
+
/**
|
|
2711
|
+
* Render the `{field, code, detail}` hints a problem body carries.
|
|
2712
|
+
*
|
|
2713
|
+
* This surface renders TEXT: anything left in `structuredContent` is invisible to
|
|
2714
|
+
* a text-only model. The server puts the full remediation prose in `detail` for
|
|
2715
|
+
* exactly that reason, and `errors[]` carries the machine duplicate — the exact
|
|
2716
|
+
* JSON to add on a 422 `intent_required`, the current revision to re-CAS against
|
|
2717
|
+
* and the legal verbs on a 409. Dropping them here is what made the remediation
|
|
2718
|
+
* unreachable.
|
|
2719
|
+
*/
|
|
2720
|
+
function renderProblemFields(fields) {
|
|
2721
|
+
if (!fields?.length)
|
|
2722
|
+
return "";
|
|
2723
|
+
const lines = fields.map((f) => ` - ${f.field} (${f.code})${f.detail ? `: ${f.detail}` : ""}`);
|
|
2724
|
+
return `\nDetails:\n${lines.join("\n")}`;
|
|
2725
|
+
}
|
|
2726
|
+
function toErrorResult(err) {
|
|
2727
|
+
if (err instanceof ExtrovertApiError) {
|
|
2728
|
+
const detail = err.status ? ` (HTTP ${err.status}${err.code ? `, ${err.code}` : ""})` : "";
|
|
2729
|
+
return {
|
|
2730
|
+
content: [
|
|
2731
|
+
{ type: "text", text: `Extrovert error${detail}: ${err.message}${renderProblemFields(err.problemErrors)}` },
|
|
2732
|
+
],
|
|
2733
|
+
isError: true,
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
// Offline-store sentinels carry a stable class name; surface the SAME problem
|
|
2737
|
+
// code/status AND the same field hints the live API would return, so an agent
|
|
2738
|
+
// that learned to recover offline recovers identically against production.
|
|
2739
|
+
if (err instanceof Error && MOCK_ERROR_MAP[err.name]) {
|
|
2740
|
+
const { status, code } = MOCK_ERROR_MAP[err.name];
|
|
2741
|
+
const fields = err.problemErrors;
|
|
2742
|
+
return {
|
|
2743
|
+
content: [
|
|
2744
|
+
{ type: "text", text: `Extrovert error (HTTP ${status}, ${code}): ${err.message}${renderProblemFields(fields)}` },
|
|
2745
|
+
],
|
|
2746
|
+
isError: true,
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2750
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
2751
|
+
}
|
|
2752
|
+
//# sourceMappingURL=tools.js.map
|