@intx/tools-mail 0.1.2

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/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @intx/tools-mail
2
+
3
+ Mail tool runner for the agent harness. Exposes `mail_send`,
4
+ `mail_reply`, `mail_search`, `mail_read`, and `mail_wait` against
5
+ the `MessageTransport` resolved from the harness's
6
+ `RuntimeCapabilities` registry.
7
+
8
+ Consumed by `apps/sidecar` and the demo examples; pairs with
9
+ `mergeToolRunners` from `@intx/harness` when composing the full
10
+ tool set the reactor sees.
11
+
12
+ ```ts
13
+ import { createMailTools } from "@intx/tools-mail";
14
+ import { mergeToolRunners } from "@intx/harness";
15
+
16
+ const mail = createMailTools({ capabilities });
17
+ const tools = mergeToolRunners([mail, posixTools]);
18
+ ```
19
+
20
+ The transport is resolved once at handler-init and held for the
21
+ deploy lifetime; the handlers do not re-consult capabilities on
22
+ each call.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@intx/tools-mail",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/types": "0.0.0",
14
+ "arktype": "^2.1.29"
15
+ }
16
+ }
@@ -0,0 +1,153 @@
1
+ // Static definitions for the five mail tools. The catalog generator and the
2
+ // inference director both consume these as inert data — no factory calls,
3
+ // no runtime side effects.
4
+ //
5
+ // (MESSAGE.md § Mail Tools)
6
+
7
+ import type { ToolDefinition } from "@intx/types/runtime";
8
+
9
+ export type MailToolName =
10
+ | "mail_send"
11
+ | "mail_reply"
12
+ | "mail_search"
13
+ | "mail_read"
14
+ | "mail_wait";
15
+
16
+ export const TOOL_DEFINITIONS: ToolDefinition[] = [
17
+ {
18
+ name: "mail_send",
19
+ description:
20
+ "Send mail to another agent or address. Use this to initiate conversations or send mail to other agents.",
21
+ inputSchema: {
22
+ type: "object",
23
+ properties: {
24
+ to: {
25
+ type: "string",
26
+ description: "Recipient address (e.g. agent@local.interchange)",
27
+ },
28
+ content: {
29
+ type: "string",
30
+ description: "Mail text content",
31
+ },
32
+ type: {
33
+ type: "string",
34
+ description: "Mail type (default: conversation.message)",
35
+ default: "conversation.message",
36
+ },
37
+ subject: {
38
+ type: "string",
39
+ description: "Optional subject line",
40
+ },
41
+ inReplyTo: {
42
+ type: "string",
43
+ description: "Message-ID of the mail being replied to",
44
+ },
45
+ },
46
+ required: ["to", "content"],
47
+ },
48
+ },
49
+ {
50
+ name: "mail_reply",
51
+ description:
52
+ "Reply to a mail by reference. Addresses the reply to the original sender and sets inReplyTo for threading.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ ref: {
57
+ type: "object",
58
+ description: "Mail reference { uid, mailbox }",
59
+ properties: {
60
+ uid: { type: "number" },
61
+ mailbox: { type: "string" },
62
+ },
63
+ required: ["uid", "mailbox"],
64
+ },
65
+ content: {
66
+ type: "string",
67
+ description: "Reply mail text content",
68
+ },
69
+ type: {
70
+ type: "string",
71
+ description: "Mail type (default: conversation.message)",
72
+ default: "conversation.message",
73
+ },
74
+ },
75
+ required: ["ref", "content"],
76
+ },
77
+ },
78
+ {
79
+ name: "mail_search",
80
+ description: "Search mail in a mailbox. Returns mail summaries.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ mailbox: {
85
+ type: "string",
86
+ description: "Mailbox to search",
87
+ default: "INBOX",
88
+ },
89
+ query: {
90
+ type: "object",
91
+ description: "Search query (e.g. { from: 'agent@...' })",
92
+ },
93
+ limit: {
94
+ type: "number",
95
+ description: "Maximum results to return",
96
+ default: 20,
97
+ },
98
+ },
99
+ },
100
+ },
101
+ {
102
+ name: "mail_read",
103
+ description: "Read a specific mail by reference.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ ref: {
108
+ type: "object",
109
+ description: "Mail reference { uid, mailbox }",
110
+ properties: {
111
+ uid: { type: "number" },
112
+ mailbox: { type: "string" },
113
+ },
114
+ required: ["uid", "mailbox"],
115
+ },
116
+ parts: {
117
+ type: "string",
118
+ description:
119
+ "What to fetch: 'full', 'headers', 'payload', or a MIME part path",
120
+ default: "payload",
121
+ },
122
+ },
123
+ required: ["ref"],
124
+ },
125
+ },
126
+ {
127
+ name: "mail_wait",
128
+ description:
129
+ "Wait for mail matching a query to arrive. Blocks until matching mail is delivered or the timeout expires. Use this instead of polling mail_search in a loop.",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ query: {
134
+ type: "object",
135
+ description:
136
+ "Search criteria for the mail to wait for (e.g. { from: 'agent@...' })",
137
+ },
138
+ timeout: {
139
+ type: "number",
140
+ description:
141
+ "Maximum seconds to wait before returning a timeout error",
142
+ default: 120,
143
+ },
144
+ mailbox: {
145
+ type: "string",
146
+ description: "Mailbox to watch",
147
+ default: "INBOX",
148
+ },
149
+ },
150
+ required: ["query"],
151
+ },
152
+ },
153
+ ];
@@ -0,0 +1,437 @@
1
+ // Per-tool handler factories for the five mail tools. Each factory takes
2
+ // the bound MessageTransport and returns a closed-over ToolHandler.
3
+ //
4
+ // Keeping the factories at this granularity (one per tool, pure
5
+ // (MessageTransport) → ToolHandler) is deliberate: the package's
6
+ // public surface in index.ts resolves the transport once at handler-init
7
+ // and wires the handlers in a single place. The factories themselves
8
+ // carry no resolver vocabulary, so a future per-tool composition can
9
+ // reuse them unchanged.
10
+ //
11
+ // (MESSAGE.md § Mail Tools)
12
+
13
+ import { type } from "arktype";
14
+ import type {
15
+ MessageTransport,
16
+ ToolCall,
17
+ ToolResult,
18
+ OutboundMessage,
19
+ SearchQuery,
20
+ } from "@intx/types/runtime";
21
+ import { InterchangeType } from "@intx/types/runtime";
22
+
23
+ export type ToolHandler = (
24
+ call: ToolCall,
25
+ signal: AbortSignal,
26
+ ) => Promise<ToolResult>;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Argument schemas
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const SendArgs = type({
33
+ to: "string | string[]",
34
+ "type?": InterchangeType,
35
+ "content?": "string",
36
+ "payload?": "Record<string, unknown>",
37
+ "subject?": "string",
38
+ "inReplyTo?": "string",
39
+ });
40
+
41
+ const ReplyArgs = type({
42
+ ref: { uid: "number", mailbox: "string" },
43
+ "type?": InterchangeType,
44
+ "content?": "string",
45
+ "payload?": "Record<string, unknown>",
46
+ });
47
+
48
+ const SearchArgs = type({
49
+ "mailbox?": "string",
50
+ "query?": "Record<string, unknown>",
51
+ "limit?": "number",
52
+ });
53
+
54
+ const ReadArgs = type({
55
+ ref: { uid: "number", mailbox: "string" },
56
+ "parts?": "string",
57
+ });
58
+
59
+ const WaitArgs = type({
60
+ "query?": "Record<string, unknown>",
61
+ "timeout?": "number",
62
+ "mailbox?": "string",
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Individual tool handlers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export function makeMailSendHandler(transport: MessageTransport): ToolHandler {
70
+ return async (call, signal) => {
71
+ const args = SendArgs(call.arguments);
72
+ if (args instanceof type.errors) {
73
+ return errorResult(call.id, args.summary);
74
+ }
75
+
76
+ const { content, payload } = args;
77
+
78
+ if (content !== undefined && payload !== undefined) {
79
+ return errorResult(
80
+ call.id,
81
+ "provide either 'content' or 'payload', not both",
82
+ );
83
+ }
84
+
85
+ const outbound: OutboundMessage = {
86
+ to: args.to,
87
+ type: args.type ?? "conversation.message",
88
+ };
89
+
90
+ if (args.subject !== undefined) {
91
+ outbound.subject = args.subject;
92
+ }
93
+ if (content !== undefined) {
94
+ outbound.content = content;
95
+ }
96
+ if (payload !== undefined) {
97
+ outbound.payload = payload;
98
+ }
99
+ if (args.inReplyTo !== undefined) {
100
+ outbound.inReplyTo = args.inReplyTo;
101
+ }
102
+
103
+ let receipt;
104
+ try {
105
+ receipt = await transport.send(outbound, signal);
106
+ } catch (cause) {
107
+ return errorResult(
108
+ call.id,
109
+ `send_failed: ${cause instanceof Error ? cause.message : String(cause)}`,
110
+ "send_failed",
111
+ );
112
+ }
113
+
114
+ return { callId: call.id, content: { messageId: receipt.messageId } };
115
+ };
116
+ }
117
+
118
+ export function makeMailReplyHandler(transport: MessageTransport): ToolHandler {
119
+ return async (call, signal) => {
120
+ const args = ReplyArgs(call.arguments);
121
+ if (args instanceof type.errors) {
122
+ return errorResult(call.id, args.summary);
123
+ }
124
+
125
+ const messageRef = args.ref;
126
+
127
+ // Fetch the parent message to retrieve threading headers.
128
+ let parentHeaders;
129
+ try {
130
+ parentHeaders = await transport.fetchHeaders(messageRef, signal);
131
+ } catch (cause) {
132
+ return errorResult(
133
+ call.id,
134
+ `failed to fetch parent message: ${cause instanceof Error ? cause.message : String(cause)}`,
135
+ );
136
+ }
137
+
138
+ const { content, payload } = args;
139
+
140
+ if (content !== undefined && payload !== undefined) {
141
+ return errorResult(
142
+ call.id,
143
+ "provide either 'content' or 'payload', not both",
144
+ );
145
+ }
146
+
147
+ const outbound: OutboundMessage = {
148
+ to: parentHeaders.from,
149
+ type: args.type ?? "conversation.message",
150
+ inReplyTo: parentHeaders.messageId,
151
+ };
152
+
153
+ // Carry forward the subject if available.
154
+ if (parentHeaders.subject !== undefined) {
155
+ outbound.subject = parentHeaders.subject;
156
+ }
157
+
158
+ if (content !== undefined) {
159
+ outbound.content = content;
160
+ }
161
+ if (payload !== undefined) {
162
+ outbound.payload = payload;
163
+ }
164
+
165
+ // OutboundMessage does not carry a References field; the transport
166
+ // builds the threading chain from inReplyTo when delivering the
167
+ // reply.
168
+
169
+ let receipt;
170
+ try {
171
+ receipt = await transport.send(outbound, signal);
172
+ } catch (cause) {
173
+ return errorResult(
174
+ call.id,
175
+ `send_failed: ${cause instanceof Error ? cause.message : String(cause)}`,
176
+ "send_failed",
177
+ );
178
+ }
179
+
180
+ return { callId: call.id, content: { messageId: receipt.messageId } };
181
+ };
182
+ }
183
+
184
+ export function makeMailSearchHandler(
185
+ transport: MessageTransport,
186
+ ): ToolHandler {
187
+ return async (call, signal) => {
188
+ const args = SearchArgs(call.arguments);
189
+ if (args instanceof type.errors) {
190
+ return errorResult(call.id, args.summary);
191
+ }
192
+
193
+ const mailbox = args.mailbox ?? "INBOX";
194
+ const query = args.query ?? {};
195
+ const limit = args.limit ?? 20;
196
+
197
+ let refs;
198
+ try {
199
+ refs = await transport.search(mailbox, query as SearchQuery, signal);
200
+ } catch (cause) {
201
+ const msg = cause instanceof Error ? cause.message : String(cause);
202
+ const code = msg.includes("does not exist")
203
+ ? "invalid_mailbox"
204
+ : "invalid_query";
205
+ return errorResult(call.id, msg, code);
206
+ }
207
+
208
+ const limited = refs.slice(0, limit);
209
+
210
+ // Fetch summary headers for each result.
211
+ const summaries = await Promise.all(
212
+ limited.map(async (ref) => {
213
+ try {
214
+ const headers = await transport.fetchHeaders(ref, signal);
215
+ return {
216
+ ref,
217
+ from: headers.from,
218
+ subject: headers.subject,
219
+ date: headers.date,
220
+ interchangeType: headers.interchangeType,
221
+ messageId: headers.messageId,
222
+ };
223
+ } catch {
224
+ return { ref };
225
+ }
226
+ }),
227
+ );
228
+
229
+ return { callId: call.id, content: { results: summaries } };
230
+ };
231
+ }
232
+
233
+ export function makeMailReadHandler(transport: MessageTransport): ToolHandler {
234
+ return async (call, signal) => {
235
+ const args = ReadArgs(call.arguments);
236
+ if (args instanceof type.errors) {
237
+ return errorResult(call.id, args.summary);
238
+ }
239
+
240
+ const messageRef = args.ref;
241
+ const parts = args.parts ?? "payload";
242
+
243
+ if (parts === "headers") {
244
+ let headers;
245
+ try {
246
+ headers = await transport.fetchHeaders(messageRef, signal);
247
+ } catch (cause) {
248
+ return errorResult(
249
+ call.id,
250
+ `not_found: ${cause instanceof Error ? cause.message : String(cause)}`,
251
+ "not_found",
252
+ );
253
+ }
254
+ return { callId: call.id, content: { headers } };
255
+ }
256
+
257
+ if (parts === "full") {
258
+ let message;
259
+ try {
260
+ message = await transport.fetchFull(messageRef, signal);
261
+ } catch (cause) {
262
+ return errorResult(
263
+ call.id,
264
+ `not_found: ${cause instanceof Error ? cause.message : String(cause)}`,
265
+ "not_found",
266
+ );
267
+ }
268
+ return {
269
+ callId: call.id,
270
+ content: {
271
+ headers: message.headers,
272
+ content: message.content,
273
+ payload: message.payload,
274
+ signatureStatus: message.signatureStatus,
275
+ flags: message.flags,
276
+ },
277
+ };
278
+ }
279
+
280
+ if (parts === "payload") {
281
+ let message;
282
+ try {
283
+ message = await transport.fetchFull(messageRef, signal);
284
+ } catch (cause) {
285
+ return errorResult(
286
+ call.id,
287
+ `not_found: ${cause instanceof Error ? cause.message : String(cause)}`,
288
+ "not_found",
289
+ );
290
+ }
291
+
292
+ if (message.payload !== undefined) {
293
+ return { callId: call.id, content: { payload: message.payload } };
294
+ }
295
+ // Conversation message — return content field.
296
+ return {
297
+ callId: call.id,
298
+ content: {
299
+ content: message.content,
300
+ interchangeType: message.headers.interchangeType,
301
+ },
302
+ };
303
+ }
304
+
305
+ // Specific MIME part path (e.g. "1.3").
306
+ let part;
307
+ try {
308
+ part = await transport.fetchPart(messageRef, parts, signal);
309
+ } catch (cause) {
310
+ return errorResult(
311
+ call.id,
312
+ `invalid_part: ${cause instanceof Error ? cause.message : String(cause)}`,
313
+ "invalid_part",
314
+ );
315
+ }
316
+
317
+ return {
318
+ callId: call.id,
319
+ content: {
320
+ contentType: part.contentType,
321
+ encoding: part.encoding,
322
+ content: new TextDecoder().decode(part.content),
323
+ },
324
+ };
325
+ };
326
+ }
327
+
328
+ export function makeMailWaitHandler(transport: MessageTransport): ToolHandler {
329
+ return async (call, signal) => {
330
+ const args = WaitArgs(call.arguments);
331
+ if (args instanceof type.errors) {
332
+ return errorResult(call.id, args.summary);
333
+ }
334
+
335
+ const query = args.query ?? {};
336
+ const timeoutSeconds = args.timeout ?? 120;
337
+ const mailbox = args.mailbox ?? "INBOX";
338
+
339
+ // Check for an existing match first.
340
+ const existing = await transport.search(
341
+ mailbox,
342
+ query as SearchQuery,
343
+ signal,
344
+ );
345
+ const firstMatch = existing[0];
346
+ if (firstMatch !== undefined) {
347
+ const message = await transport.fetchFull(firstMatch, signal);
348
+ return {
349
+ callId: call.id,
350
+ content: {
351
+ ref: firstMatch,
352
+ from: message.headers.from,
353
+ subject: message.headers.subject,
354
+ content: message.content,
355
+ },
356
+ };
357
+ }
358
+
359
+ // No match yet — watch for new arrivals.
360
+ return new Promise<ToolResult>((resolve) => {
361
+ let settled = false;
362
+
363
+ const unsubscribe = transport.watch(mailbox, (event) => {
364
+ if (settled) return;
365
+ if (event.type !== "exists") return;
366
+
367
+ // Match against the query's 'from' field (the primary use case).
368
+ if (
369
+ typeof query.from === "string" &&
370
+ event.headers.from !== query.from
371
+ ) {
372
+ return;
373
+ }
374
+
375
+ settled = true;
376
+ unsubscribe();
377
+ clearTimeout(timer);
378
+
379
+ void (async () => {
380
+ const ref = { uid: event.uid, mailbox };
381
+ const message = await transport.fetchFull(ref, signal);
382
+ resolve({
383
+ callId: call.id,
384
+ content: {
385
+ ref,
386
+ from: message.headers.from,
387
+ subject: message.headers.subject,
388
+ content: message.content,
389
+ },
390
+ });
391
+ })();
392
+ });
393
+
394
+ const timer = setTimeout(() => {
395
+ if (settled) return;
396
+ settled = true;
397
+ unsubscribe();
398
+ resolve(
399
+ errorResult(
400
+ call.id,
401
+ `Timed out after ${timeoutSeconds}s waiting for a matching message`,
402
+ "timeout",
403
+ ),
404
+ );
405
+ }, timeoutSeconds * 1000);
406
+
407
+ // Respect the abort signal.
408
+ signal.addEventListener(
409
+ "abort",
410
+ () => {
411
+ if (settled) return;
412
+ settled = true;
413
+ unsubscribe();
414
+ clearTimeout(timer);
415
+ resolve(errorResult(call.id, "aborted", "aborted"));
416
+ },
417
+ { once: true },
418
+ );
419
+ });
420
+ };
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Helper
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function errorResult(
428
+ callId: string,
429
+ message: string,
430
+ code?: string,
431
+ ): ToolResult {
432
+ const content: Record<string, unknown> = { error: message };
433
+ if (code !== undefined) {
434
+ content["code"] = code;
435
+ }
436
+ return { callId, content, isError: true };
437
+ }