@desplega.ai/agent-swarm 1.83.1 → 1.84.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.
Files changed (69) hide show
  1. package/openapi.json +158 -8
  2. package/package.json +1 -1
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/http/webhooks.ts +101 -0
  19. package/src/integrations/kapso/client.ts +198 -0
  20. package/src/integrations/kapso/config.ts +104 -0
  21. package/src/integrations/kapso/inbound.ts +111 -0
  22. package/src/jira/client.ts +3 -5
  23. package/src/jira/oauth.ts +1 -0
  24. package/src/jira/sync.ts +2 -2
  25. package/src/oauth/ensure-token.ts +1 -0
  26. package/src/oauth/wrapper.ts +38 -7
  27. package/src/providers/claude-adapter.ts +7 -2
  28. package/src/providers/claude-managed-adapter.ts +1 -1
  29. package/src/providers/codex-adapter.ts +30 -0
  30. package/src/providers/opencode-adapter.ts +149 -14
  31. package/src/providers/pi-mono-adapter.ts +41 -1
  32. package/src/providers/types.ts +1 -1
  33. package/src/server-user.ts +117 -0
  34. package/src/server.ts +14 -0
  35. package/src/tests/artifact-sdk.test.ts +23 -19
  36. package/src/tests/budget-user-scope.test.ts +376 -0
  37. package/src/tests/claude-managed-adapter.test.ts +6 -0
  38. package/src/tests/codex-adapter.test.ts +192 -0
  39. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  40. package/src/tests/db-queries-oauth.test.ts +43 -0
  41. package/src/tests/ensure-token.test.ts +93 -0
  42. package/src/tests/error-tracker.test.ts +52 -0
  43. package/src/tests/fetch-resolved-env.test.ts +33 -20
  44. package/src/tests/http-users.test.ts +29 -1
  45. package/src/tests/kapso-client.test.ts +94 -0
  46. package/src/tests/kapso-inbound.test.ts +198 -0
  47. package/src/tests/mcp-user-route.test.ts +325 -0
  48. package/src/tests/opencode-adapter.test.ts +75 -0
  49. package/src/tests/pi-mono-adapter.test.ts +21 -1
  50. package/src/tests/rate-limit-event.test.ts +69 -6
  51. package/src/tests/resume-session.test.ts +93 -0
  52. package/src/tests/task-tools-ctx.test.ts +100 -0
  53. package/src/tests/task-tools-ownership.test.ts +167 -0
  54. package/src/tests/tool-annotations.test.ts +3 -2
  55. package/src/tests/user-token-routes.test.ts +221 -0
  56. package/src/tools/cancel-task.ts +137 -83
  57. package/src/tools/get-task-details.ts +73 -59
  58. package/src/tools/get-tasks.ts +134 -126
  59. package/src/tools/register-kapso-number.ts +210 -0
  60. package/src/tools/send-task.ts +312 -312
  61. package/src/tools/task-action.ts +464 -367
  62. package/src/tools/task-tool-ctx.ts +43 -0
  63. package/src/tools/templates.ts +35 -0
  64. package/src/tools/tool-config.ts +6 -0
  65. package/src/tools/whatsapp-message.ts +135 -0
  66. package/src/types.ts +6 -2
  67. package/src/utils/error-tracker.ts +122 -9
  68. package/templates/skills/agentmail-sending/SKILL.md +49 -0
  69. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
@@ -0,0 +1,43 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { AgentTask, User } from "@/types";
3
+ import type { RequestInfo } from "./utils";
4
+
5
+ export type ToolCtx =
6
+ | { kind: "owner"; agentId?: string; sourceTaskId?: string; sessionId?: string }
7
+ | { kind: "user"; userId: string; user: User; sessionId?: string };
8
+
9
+ export function ownerCtx(info: RequestInfo): ToolCtx {
10
+ return {
11
+ kind: "owner",
12
+ agentId: info.agentId,
13
+ sourceTaskId: info.sourceTaskId,
14
+ sessionId: info.sessionId,
15
+ };
16
+ }
17
+
18
+ export function userCtx(user: User, sessionId?: string): ToolCtx {
19
+ return {
20
+ kind: "user",
21
+ userId: user.id,
22
+ user,
23
+ sessionId,
24
+ };
25
+ }
26
+
27
+ export function assertOwnsTask(ctx: ToolCtx, task: AgentTask): CallToolResult | null {
28
+ if (ctx.kind === "owner" || task.requestedByUserId === ctx.userId) {
29
+ return null;
30
+ }
31
+
32
+ const message = `Forbidden: this task is not yours (task ${task.id}).`;
33
+ // RBAC chokepoint — a future admin/role tier widens visibility here, in this one function.
34
+ return {
35
+ isError: true,
36
+ content: [{ type: "text", text: message }],
37
+ structuredContent: {
38
+ success: false,
39
+ code: "forbidden",
40
+ message,
41
+ },
42
+ };
43
+ }
@@ -7,6 +7,41 @@
7
7
 
8
8
  import { registerTemplate } from "../prompts/registry";
9
9
 
10
+ // ============================================================================
11
+ // Kapso/WhatsApp inbound (native handler creates a kapso-inbound task)
12
+ // ============================================================================
13
+
14
+ registerTemplate({
15
+ eventType: "kapso.message.received",
16
+ header: "",
17
+ defaultBody: `# WhatsApp inbound (Kapso)
18
+
19
+ A Kapso webhook fired on the swarm's provisioned WhatsApp number. Load the \`kapso-whatsapp\` skill, then triage this like any other interaction and reply on WhatsApp by quote-replying the inbound WAMID (\`context.message_id\`).
20
+
21
+ ## Source: WhatsApp (Kapso)
22
+ - conversation_id: {{conversation_id}}
23
+ - inbound_wamid: {{inbound_wamid}}
24
+ - sender_phone: {{sender_phone}}
25
+ - contact_name: {{contact_name}}
26
+ - phone_number_id: {{phone_number_id}}{{test_note}}
27
+
28
+ ## Message
29
+ {{message_text}}`,
30
+ variables: [
31
+ { name: "conversation_id", description: "Kapso conversation id, or 'unknown'" },
32
+ { name: "inbound_wamid", description: "Inbound message WAMID, or 'unknown'" },
33
+ { name: "sender_phone", description: "Sender phone (E.164 no +), or 'unknown'" },
34
+ { name: "contact_name", description: "Contact display name, or 'unknown'" },
35
+ { name: "phone_number_id", description: "Provisioned phone-number id, or 'unknown'" },
36
+ {
37
+ name: "test_note",
38
+ description: "Appended note when the payload is a Kapso test delivery (else empty)",
39
+ },
40
+ { name: "message_text", description: "Inbound message text or a non-text placeholder" },
41
+ ],
42
+ category: "event",
43
+ });
44
+
10
45
  // ============================================================================
11
46
  // Worker task follow-ups (created by store-progress for the lead)
12
47
  // ============================================================================
@@ -98,6 +98,12 @@ export const DEFERRED_TOOLS = new Set([
98
98
  // AgentMail (1)
99
99
  "register-agentmail-inbox",
100
100
 
101
+ // Kapso/WhatsApp (4)
102
+ "register-kapso-number",
103
+ "unregister-kapso-number",
104
+ "send-whatsapp-message",
105
+ "reply-whatsapp-message",
106
+
101
107
  // Tracker (6)
102
108
  "tracker-status",
103
109
  "tracker-link-task",
@@ -0,0 +1,135 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { sendKapsoText } from "@/integrations/kapso/client";
4
+ import { getKapsoConfig } from "@/integrations/kapso/config";
5
+ import { createToolRegistrar } from "@/tools/utils";
6
+
7
+ /** Shared structured-error message for the 24h session-window case. */
8
+ const SESSION_WINDOW_HINT =
9
+ 'Outside the 24h WhatsApp session window — free-form text is rejected. Use a pre-approved template message (see the `kapso-whatsapp` skill, "Send a template").';
10
+
11
+ const outputSchema = z.object({
12
+ yourAgentId: z.string().uuid().optional(),
13
+ success: z.boolean(),
14
+ message: z.string(),
15
+ messageId: z.string().optional(),
16
+ sessionWindowExpired: z.boolean().optional(),
17
+ });
18
+
19
+ export const registerSendWhatsappMessageTool = (server: McpServer) => {
20
+ createToolRegistrar(server)(
21
+ "send-whatsapp-message",
22
+ {
23
+ title: "Send WhatsApp Message",
24
+ annotations: { openWorldHint: true },
25
+ description:
26
+ "Send a free-form WhatsApp text via Kapso (within the 24h session window). Thin wrapper over the Kapso Meta-proxy send. For templates/media/reactions use the `kapso-whatsapp` skill. If the recipient is outside the 24h window the call returns a structured error pointing at the template path.",
27
+ inputSchema: z.object({
28
+ phoneNumberId: z
29
+ .string()
30
+ .min(1)
31
+ .describe("The swarm's Kapso/Meta phone-number ID to send from (KAPSO_PHONE_NUMBER_ID)."),
32
+ to: z
33
+ .string()
34
+ .min(1)
35
+ .describe("Recipient phone in E.164 WITHOUT '+' (e.g. '15551234567')."),
36
+ body: z.string().min(1).describe("Message text."),
37
+ previewUrl: z
38
+ .boolean()
39
+ .optional()
40
+ .describe("Render a link preview for URLs in the body (default false)."),
41
+ }),
42
+ outputSchema,
43
+ },
44
+ async ({ phoneNumberId, to, body, previewUrl }, requestInfo) => {
45
+ return sendAndFormat({ phoneNumberId, to, body, previewUrl }, requestInfo.agentId, undefined);
46
+ },
47
+ );
48
+ };
49
+
50
+ export const registerReplyWhatsappMessageTool = (server: McpServer) => {
51
+ createToolRegistrar(server)(
52
+ "reply-whatsapp-message",
53
+ {
54
+ title: "Reply to WhatsApp Message",
55
+ annotations: { openWorldHint: true },
56
+ description:
57
+ "Quote-reply a WhatsApp message via Kapso — same as send-whatsapp-message but threads to a specific inbound WAMID via context.message_id. Recipient is inferred from the conversation; pass the original sender's phone as `to`.",
58
+ inputSchema: z.object({
59
+ phoneNumberId: z
60
+ .string()
61
+ .min(1)
62
+ .describe("The swarm's Kapso/Meta phone-number ID to send from (KAPSO_PHONE_NUMBER_ID)."),
63
+ to: z.string().min(1).describe("Recipient phone in E.164 WITHOUT '+'."),
64
+ inReplyTo: z
65
+ .string()
66
+ .min(1)
67
+ .describe("The inbound WAMID to quote-reply (set as context.message_id)."),
68
+ body: z.string().min(1).describe("Reply text."),
69
+ }),
70
+ outputSchema,
71
+ },
72
+ async ({ phoneNumberId, to, inReplyTo, body }, requestInfo) => {
73
+ return sendAndFormat({ phoneNumberId, to, body }, requestInfo.agentId, inReplyTo);
74
+ },
75
+ );
76
+ };
77
+
78
+ async function sendAndFormat(
79
+ params: { phoneNumberId: string; to: string; body: string; previewUrl?: boolean },
80
+ agentId: string | undefined,
81
+ contextMessageId: string | undefined,
82
+ ) {
83
+ try {
84
+ const config = getKapsoConfig();
85
+ if (!config.apiKey) {
86
+ const msg = "KAPSO_API_KEY is not configured in swarm config.";
87
+ return {
88
+ content: [{ type: "text" as const, text: msg }],
89
+ structuredContent: { yourAgentId: agentId, success: false, message: msg },
90
+ };
91
+ }
92
+
93
+ const result = await sendKapsoText({
94
+ apiBaseUrl: config.apiBaseUrl,
95
+ apiKey: config.apiKey,
96
+ phoneNumberId: params.phoneNumberId,
97
+ to: params.to,
98
+ body: params.body,
99
+ previewUrl: params.previewUrl,
100
+ contextMessageId,
101
+ });
102
+
103
+ if (result.ok) {
104
+ const text = `Sent WhatsApp message to ${params.to} (wamid ${result.messageId ?? "unknown"})`;
105
+ return {
106
+ content: [{ type: "text" as const, text }],
107
+ structuredContent: {
108
+ yourAgentId: agentId,
109
+ success: true,
110
+ message: text,
111
+ messageId: result.messageId,
112
+ },
113
+ };
114
+ }
115
+
116
+ const text = result.sessionWindowExpired
117
+ ? `${SESSION_WINDOW_HINT} (Kapso: ${result.errorMessage})`
118
+ : `Kapso send failed: ${result.errorMessage}`;
119
+ return {
120
+ content: [{ type: "text" as const, text }],
121
+ structuredContent: {
122
+ yourAgentId: agentId,
123
+ success: false,
124
+ message: text,
125
+ sessionWindowExpired: result.sessionWindowExpired,
126
+ },
127
+ };
128
+ } catch (err) {
129
+ const errorMessage = err instanceof Error ? err.message : String(err);
130
+ return {
131
+ content: [{ type: "text" as const, text: `Error: ${errorMessage}` }],
132
+ structuredContent: { yourAgentId: agentId, success: false, message: errorMessage },
133
+ };
134
+ }
135
+ }
package/src/types.ts CHANGED
@@ -1682,7 +1682,7 @@ export type ContextSnapshot = z.infer<typeof ContextSnapshotSchema>;
1682
1682
  // effective_from <= now" lookup is a pure integer comparison. Matches the
1683
1683
  // SQL columns in migration 046_budgets_and_pricing.sql verbatim.
1684
1684
 
1685
- export const BudgetScopeSchema = z.enum(["global", "agent"]);
1685
+ export const BudgetScopeSchema = z.enum(["global", "agent", "user"]);
1686
1686
  export type BudgetScope = z.infer<typeof BudgetScopeSchema>;
1687
1687
 
1688
1688
  export const BudgetSchema = z.object({
@@ -1730,7 +1730,7 @@ export const PricingRowSchema = z.object({
1730
1730
  });
1731
1731
  export type PricingRow = z.infer<typeof PricingRowSchema>;
1732
1732
 
1733
- export const BudgetRefusalCauseSchema = z.enum(["agent", "global"]);
1733
+ export const BudgetRefusalCauseSchema = z.enum(["agent", "global", "user"]);
1734
1734
  export type BudgetRefusalCause = z.infer<typeof BudgetRefusalCauseSchema>;
1735
1735
 
1736
1736
  export const BudgetRefusalNotificationSchema = z.object({
@@ -1742,6 +1742,8 @@ export const BudgetRefusalNotificationSchema = z.object({
1742
1742
  agentBudgetUsd: z.number().nullable().optional(),
1743
1743
  globalSpendUsd: z.number().nullable().optional(),
1744
1744
  globalBudgetUsd: z.number().nullable().optional(),
1745
+ userSpendUsd: z.number().nullable().optional(),
1746
+ userBudgetUsd: z.number().nullable().optional(),
1745
1747
  followUpTaskId: z.string().nullable().optional(),
1746
1748
  createdAt: z.number(), // epoch ms
1747
1749
  });
@@ -1761,6 +1763,8 @@ export const BudgetRefusedTriggerSchema = z.object({
1761
1763
  agentBudget: z.number().optional(),
1762
1764
  globalSpend: z.number().optional(),
1763
1765
  globalBudget: z.number().optional(),
1766
+ userSpend: z.number().optional(),
1767
+ userBudget: z.number().optional(),
1764
1768
  resetAt: z.string(), // ISO 8601, next UTC midnight
1765
1769
  });
1766
1770
  export type BudgetRefusedTrigger = z.infer<typeof BudgetRefusedTriggerSchema>;
@@ -11,13 +11,34 @@ export interface ErrorSignal {
11
11
  }
12
12
 
13
13
  /**
14
- * Clamps a candidate reset timestamp (ms) to [now+60s, now+6h].
14
+ * Maximum cooldown horizon for a rate-limit reset. A weekly OAuth limit resets
15
+ * up to ~7 days out, so the cap must be at least that or a weekly-limited key
16
+ * gets re-clamped to a short cooldown and re-handed to a worker every few hours
17
+ * (the fail-every-6h sawtooth). 7d still guards against absurd far-future
18
+ * (malformed) values.
19
+ */
20
+ export const MAX_RATE_LIMIT_RESET_MS = 7 * 24 * 60 * 60 * 1000;
21
+
22
+ /**
23
+ * Single source of truth for "does this text look like a rate-limit signal?".
24
+ * Shared by the runner's cooldown gate and {@link parseStderrForErrors} so the
25
+ * two matchers can't drift. Tolerates a qualifier between "your" and "limit"
26
+ * (weekly / 5-hour / daily): matches "hit your weekly limit", "hit your 5-hour
27
+ * limit", "hit your limit", "Claude usage limit reached", "rate limit exceeded",
28
+ * "429 Too Many Requests"; does not match "No conversation found with session ID".
29
+ */
30
+ export function isRateLimitMessage(s: string): boolean {
31
+ return /rate.?limit|hit your[\w\s-]*limit|usage[ _-]?limit|too many requests|\b429\b/i.test(s);
32
+ }
33
+
34
+ /**
35
+ * Clamps a candidate reset timestamp (ms) to [now+60s, now+7d].
15
36
  * Protects against past timestamps (clock skew) and absurdly far future values (malformed).
16
37
  */
17
38
  function clampRateLimitResetMs(candidateMs: number): number {
18
39
  const nowMs = Date.now();
19
40
  const minMs = nowMs + 60_000;
20
- const maxMs = nowMs + 6 * 60 * 60 * 1000;
41
+ const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
21
42
  return Math.min(Math.max(candidateMs, minMs), maxMs);
22
43
  }
23
44
 
@@ -96,6 +117,26 @@ export class SessionErrorTracker {
96
117
  }
97
118
  }
98
119
 
120
+ /**
121
+ * Process a Codex-style usage-limit error message (from a `{type:"error"}`
122
+ * or `{type:"turn.failed"}` SDK event). Only stashes when the message
123
+ * contains the usage-limit signature AND carries a parseable wall-clock
124
+ * reset time. "Try again later." and workspace-credit branches fall through
125
+ * to the runner's tier-3 fallback instead.
126
+ * Last call wins — multiple events per session are deduped to the latest.
127
+ */
128
+ processCodexUsageLimitMessage(message: string): void {
129
+ if (!message) return;
130
+ if (!/usage limit|hit your usage/i.test(message)) return;
131
+
132
+ const iso = parseCodexRateLimitResetTime(message);
133
+ if (!iso) return;
134
+
135
+ const candidateMs = new Date(iso).getTime();
136
+ if (!Number.isFinite(candidateMs)) return;
137
+ this.rateLimitResetAtMs = clampRateLimitResetMs(candidateMs);
138
+ }
139
+
99
140
  /**
100
141
  * Returns the stashed rate limit reset time as an ISO string, or undefined
101
142
  * if no rejected rate_limit_event was seen in this session.
@@ -173,7 +214,14 @@ export class SessionErrorTracker {
173
214
 
174
215
  /** Check if the failure was due to a missing/stale session ID */
175
216
  isSessionNotFound(): boolean {
176
- return this.errors.some((e) => e.message.includes("No conversation found with session ID"));
217
+ return this.errors.some((e) => {
218
+ const message = e.message.toLowerCase();
219
+ return (
220
+ message.includes("no conversation found with session id") ||
221
+ (message.includes("--resume requires a valid session id") &&
222
+ message.includes("does not match any session title"))
223
+ );
224
+ });
177
225
  }
178
226
 
179
227
  getErrors(): ReadonlyArray<ErrorSignal> {
@@ -250,6 +298,76 @@ const MONTH_NAMES: Record<string, number> = {
250
298
  december: 11,
251
299
  };
252
300
 
301
+ /**
302
+ * Parse the reset time embedded in a Codex `UsageLimitReached` error message.
303
+ * Codex emits one of these formats via chrono's `%-I:%M %p` (same day) or
304
+ * `%b %-d{th/st/nd/rd}, %Y %-I:%M %p` (different day):
305
+ * "Try again at 8:35 PM."
306
+ * "or try again at 8:35 PM."
307
+ * "Try again at May 26th, 2026 8:35 PM."
308
+ * "or try again at May 26th, 2026 8:35 PM."
309
+ * Wall-clock times are UTC because the agent-swarm Docker worker has TZ=Etc/UTC;
310
+ * chrono::Local resolves to UTC in that container.
311
+ */
312
+ export function parseCodexRateLimitResetTime(
313
+ message: string,
314
+ now: Date = new Date(),
315
+ ): string | undefined {
316
+ if (!message) return undefined;
317
+
318
+ // Different-day format (more specific — try first):
319
+ // "Month Day{st/nd/rd/th}, Year HH:MM AM/PM"
320
+ const datedMatch = message.match(
321
+ /\btry again at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s+(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\b/i,
322
+ );
323
+ if (datedMatch) {
324
+ const monthIdx = MONTH_NAMES[datedMatch[1]!.toLowerCase()];
325
+ if (monthIdx !== undefined) {
326
+ const day = Number.parseInt(datedMatch[2]!, 10);
327
+ const year = Number.parseInt(datedMatch[3]!, 10);
328
+ const rawHours = Number.parseInt(datedMatch[4]!, 10);
329
+ const minutes = Number.parseInt(datedMatch[5]!, 10);
330
+ const ampm = datedMatch[6]!.toLowerCase();
331
+ if (rawHours < 1 || rawHours > 12 || minutes < 0 || minutes > 59) return undefined;
332
+ let hours = rawHours;
333
+ if (ampm === "pm" && hours !== 12) hours += 12;
334
+ if (ampm === "am" && hours === 12) hours = 0;
335
+ const d = new Date(Date.UTC(year, monthIdx, day, hours, minutes, 0));
336
+ // Round-trip guard: Date.UTC silently normalises out-of-range days (e.g. May 32 → June 1).
337
+ if (d.getUTCFullYear() !== year || d.getUTCMonth() !== monthIdx || d.getUTCDate() !== day) {
338
+ return undefined;
339
+ }
340
+ return d.toISOString();
341
+ }
342
+ }
343
+
344
+ // Same-day format: "HH:MM AM/PM"
345
+ // Anchored on "try again at" so we don't match times elsewhere in the message.
346
+ const timeMatch = message.match(/\btry again at\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\b/i);
347
+ if (timeMatch) {
348
+ const rawHours = Number.parseInt(timeMatch[1]!, 10);
349
+ const minutes = Number.parseInt(timeMatch[2]!, 10);
350
+ const ampm = timeMatch[3]!.toLowerCase();
351
+ if (rawHours < 1 || rawHours > 12 || minutes < 0 || minutes > 59) return undefined;
352
+ let hours = rawHours;
353
+ if (ampm === "pm" && hours !== 12) hours += 12;
354
+ if (ampm === "am" && hours === 12) hours = 0;
355
+ const candidate = new Date(
356
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0),
357
+ );
358
+ // Rollover: if the parsed wall-clock is more than SKEW_MS before "now", assume tomorrow.
359
+ // At-or-just-before-now candidates (clock skew, second truncation) stay same-day and
360
+ // flow to clampRateLimitResetMs which applies the now+60s floor.
361
+ const SKEW_MS = 2 * 60 * 1000;
362
+ if (candidate.getTime() < now.getTime() - SKEW_MS) {
363
+ candidate.setUTCDate(candidate.getUTCDate() + 1);
364
+ }
365
+ return candidate.toISOString();
366
+ }
367
+
368
+ return undefined;
369
+ }
370
+
253
371
  /**
254
372
  * Parse a rate limit error message to extract a reset time, returning an ISO datetime string.
255
373
  * Handles patterns like:
@@ -338,12 +456,7 @@ export function parseStderrForErrors(stderr: string, tracker: SessionErrorTracke
338
456
  const lower = stderr.toLowerCase();
339
457
  const firstLine = stderr.trim().split("\n")[0] ?? stderr.trim();
340
458
 
341
- if (
342
- lower.includes("rate limit") ||
343
- lower.includes("rate_limit") ||
344
- lower.includes("429") ||
345
- lower.includes("hit your limit")
346
- ) {
459
+ if (isRateLimitMessage(stderr)) {
347
460
  tracker.addStderrError(firstLine);
348
461
  } else if (
349
462
  lower.includes("authentication") ||
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: agentmail-sending
3
+ description: CRITICAL rules for sending emails via AgentMail API. Covers the HTML bug workaround, BCC policy, and best practices. ALL agents MUST follow these rules when using send_message or reply_to_message.
4
+ user-invocable: false
5
+ ---
6
+
7
+ # AgentMail Sending Rules
8
+
9
+ These rules are MANDATORY for all agents sending email via AgentMail. Violating them will result in blank emails reaching real people.
10
+
11
+ ## Rule 1: TEXT ONLY — Never Pass `html` Parameter
12
+
13
+ **AgentMail has a critical bug (as of 2026-03-25):** When both `text` and `html` parameters are passed to `send_message` or `reply_to_message`, the HTML body content is silently dropped. The resulting email has an empty `<div dir="ltr"></div>`. Email clients (Gmail, etc.) prefer the HTML version over plain text, so recipients see a completely blank email.
14
+
15
+ **What to do:**
16
+ - ONLY pass the `text` parameter
17
+ - NEVER pass the `html` parameter
18
+ - This applies to BOTH `send_message` and `reply_to_message`
19
+
20
+ **Why this matters:** This bug causes outbound emails to arrive completely blank, burning contacts permanently. It is not a cosmetic issue — it is a data loss / reputation issue.
21
+
22
+ ## Rule 2: BCC a Human Oversight Address on Outbound Emails
23
+
24
+ All outbound emails to external recipients MUST include a human oversight email address as BCC. This gives your team visibility into what the swarm is sending on your behalf.
25
+
26
+ **Configure a BCC oversight address for your swarm** (e.g. a founder address, ops inbox, or shared team address):
27
+
28
+ ```
29
+ send_message({
30
+ inboxId: "<your-agentmail-inbox-id>",
31
+ to: ["recipient@example.com"],
32
+ bcc: ["oversight@yourcompany.com"],
33
+ subject: "...",
34
+ text: "..."
35
+ })
36
+ ```
37
+
38
+ **Exception:** Internal emails between your swarm's own agent inboxes do NOT need BCC.
39
+
40
+ ## Rule 3: Human Approval Before Sending to External Recipients
41
+
42
+ Never send outreach or cold emails to external recipients without explicit human approval. Draft the emails, present them for review, and only send after receiving "approved" or equivalent confirmation.
43
+
44
+ ## Summary Checklist
45
+
46
+ Before every `send_message` or `reply_to_message` call:
47
+ - [ ] Only `text` param, NO `html` param
48
+ - [ ] BCC your oversight address if recipient is external
49
+ - [ ] Human-approved if it is outreach/cold email