@intx/tools-mail 0.1.2 → 0.3.0

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/src/handlers.ts DELETED
@@ -1,437 +0,0 @@
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
- }
package/src/index.ts DELETED
@@ -1,98 +0,0 @@
1
- // Public surface for @intx/tools-mail.
2
- //
3
- // createMailTools resolves the bound agent's MessageTransport from the
4
- // supplied RuntimeCapabilities once at handler-init and wires the five
5
- // mail handlers around it. The returned MailTools satisfies the
6
- // ToolRunner contract the harness consumes.
7
-
8
- import type {
9
- ToolDefinition,
10
- ToolRunner,
11
- ToolResult,
12
- } from "@intx/types/runtime";
13
- import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
14
-
15
- import { TOOL_DEFINITIONS } from "./definitions";
16
- import {
17
- makeMailReadHandler,
18
- makeMailReplyHandler,
19
- makeMailSearchHandler,
20
- makeMailSendHandler,
21
- makeMailWaitHandler,
22
- type ToolHandler,
23
- } from "./handlers";
24
-
25
- export { TOOL_DEFINITIONS } from "./definitions";
26
- export type { MailToolName } from "./definitions";
27
-
28
- export interface MailToolsOptions {
29
- capabilities: RuntimeCapabilities;
30
- }
31
-
32
- export interface MailTools extends ToolRunner {
33
- readonly definitions: ToolDefinition[];
34
- dispose(): Promise<void>;
35
- }
36
-
37
- export function createMailTools(opts: MailToolsOptions): MailTools {
38
- // Resolve the transport once at handler-init. The lifecycle contract is
39
- // "request once at handler-init, hold the handle for the deploy
40
- // lifetime"; the handler factories below close over the resolved
41
- // handle and do not re-consult capabilities.
42
- const transport = opts.capabilities.resolve("mail.transport");
43
-
44
- const handlers = new Map<string, ToolHandler>([
45
- ["mail_send", makeMailSendHandler(transport)],
46
- ["mail_reply", makeMailReplyHandler(transport)],
47
- ["mail_search", makeMailSearchHandler(transport)],
48
- ["mail_read", makeMailReadHandler(transport)],
49
- ["mail_wait", makeMailWaitHandler(transport)],
50
- ]);
51
-
52
- let disposed = false;
53
-
54
- return {
55
- definitions: TOOL_DEFINITIONS,
56
- async run(call, signal): Promise<ToolResult> {
57
- const handler = handlers.get(call.name);
58
- if (handler === undefined) {
59
- // This branch is unreachable in the sidecar composition
60
- // (mergeToolRunners dispatches by definition.name and only
61
- // forwards mail-tool calls here). It exists so callers that
62
- // use createMailTools as a standalone ToolRunner get the
63
- // package's native object-shaped error.
64
- return {
65
- callId: call.id,
66
- content: { error: `Unknown tool: "${call.name}"` },
67
- isError: true,
68
- };
69
- }
70
- try {
71
- return await handler(call, signal);
72
- } catch (err) {
73
- // Match the per-handler errorResult shape so consumers see a
74
- // single error-content shape regardless of which path produced
75
- // it: { error: <string>, code?: <string> }.
76
- const message =
77
- err instanceof Error ? err.message : `unknown error: ${String(err)}`;
78
- return {
79
- callId: call.id,
80
- content: { error: message },
81
- isError: true,
82
- };
83
- }
84
- },
85
- async dispose() {
86
- if (disposed) return;
87
- disposed = true;
88
- // No-op today: the transport is owned by the host that
89
- // constructed it, and the only handler with active resources
90
- // (mail_wait subscribes via transport.watch and registers a
91
- // setTimeout / abort listener) releases them through the
92
- // per-call AbortSignal rather than through this dispose hook.
93
- // dispose exists for symmetry with createPosixTools and as a
94
- // seam for any future per-package resources; callers must not
95
- // rely on it to cancel in-flight tool calls.
96
- },
97
- };
98
- }