@desplega.ai/agent-swarm 1.83.2 → 1.84.1
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 +48 -8
- package/openapi.json +24 -3
- package/package.json +1 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +28 -1
- package/src/http/users.ts +11 -3
- package/src/http/webhooks.ts +101 -0
- package/src/integrations/kapso/client.ts +198 -0
- package/src/integrations/kapso/config.ts +104 -0
- package/src/integrations/kapso/inbound.ts +147 -0
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/server.ts +14 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +33 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +257 -0
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tools/cancel-task.ts +13 -5
- package/src/tools/get-task-details.ts +18 -10
- package/src/tools/get-tasks.ts +9 -4
- package/src/tools/register-kapso-number.ts +210 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/task-action.ts +20 -10
- package/src/tools/templates.ts +35 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/tools/whatsapp-message.ts +135 -0
- package/templates/skills/agentmail-sending/SKILL.md +169 -0
- package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin Kapso REST client for the native integration. Pure `fetch` — no DB access.
|
|
3
|
+
*
|
|
4
|
+
* Two Kapso surfaces are used:
|
|
5
|
+
* - Meta Cloud API proxy: `{base}/meta/whatsapp/v24.0/{phoneNumberId}/messages`
|
|
6
|
+
* - Kapso platform API: `{base}/platform/v1/whatsapp/phone_numbers/{id}/webhooks`
|
|
7
|
+
*
|
|
8
|
+
* Both authenticate with the `X-API-Key` header.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Result of an outbound text/reply send through the Meta proxy. */
|
|
12
|
+
export interface KapsoSendResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
status: number;
|
|
15
|
+
/** Outbound WAMID when the send succeeded. */
|
|
16
|
+
messageId?: string;
|
|
17
|
+
raw: unknown;
|
|
18
|
+
/** True when Kapso/Meta rejected the send for being outside the 24h session window. */
|
|
19
|
+
sessionWindowExpired: boolean;
|
|
20
|
+
errorMessage?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Meta error codes that mean "outside the 24h customer-service window". */
|
|
24
|
+
const SESSION_WINDOW_ERROR_CODES = new Set([131047, 131051, 470]);
|
|
25
|
+
|
|
26
|
+
function extractMetaError(raw: unknown): { code?: number; message?: string } {
|
|
27
|
+
if (raw && typeof raw === "object" && "error" in raw) {
|
|
28
|
+
const err = (raw as { error?: { code?: number; message?: string } }).error;
|
|
29
|
+
if (err) return { code: err.code, message: err.message };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isSessionWindowError(raw: unknown): boolean {
|
|
35
|
+
const { code, message } = extractMetaError(raw);
|
|
36
|
+
if (code !== undefined && SESSION_WINDOW_ERROR_CODES.has(code)) return true;
|
|
37
|
+
const text = (message ?? "").toLowerCase();
|
|
38
|
+
return text.includes("24 hours") || text.includes("re-engagement") || text.includes("outside");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function parseJsonSafe(res: Response): Promise<unknown> {
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
if (!text) return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Send a free-form WhatsApp text message via the Meta Cloud API proxy. When
|
|
53
|
+
* `contextMessageId` is set, the message renders as a quote-reply to that WAMID.
|
|
54
|
+
*/
|
|
55
|
+
export async function sendKapsoText(params: {
|
|
56
|
+
apiBaseUrl: string;
|
|
57
|
+
apiKey: string;
|
|
58
|
+
phoneNumberId: string;
|
|
59
|
+
to: string;
|
|
60
|
+
body: string;
|
|
61
|
+
previewUrl?: boolean;
|
|
62
|
+
contextMessageId?: string;
|
|
63
|
+
}): Promise<KapsoSendResult> {
|
|
64
|
+
const url = `${params.apiBaseUrl}/meta/whatsapp/v24.0/${params.phoneNumberId}/messages`;
|
|
65
|
+
const payload: Record<string, unknown> = {
|
|
66
|
+
messaging_product: "whatsapp",
|
|
67
|
+
recipient_type: "individual",
|
|
68
|
+
to: params.to,
|
|
69
|
+
type: "text",
|
|
70
|
+
text: { preview_url: params.previewUrl ?? false, body: params.body },
|
|
71
|
+
};
|
|
72
|
+
if (params.contextMessageId) {
|
|
73
|
+
payload.context = { message_id: params.contextMessageId };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
|
|
79
|
+
body: JSON.stringify(payload),
|
|
80
|
+
});
|
|
81
|
+
const raw = await parseJsonSafe(res);
|
|
82
|
+
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const { message } = extractMetaError(raw);
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
status: res.status,
|
|
88
|
+
raw,
|
|
89
|
+
sessionWindowExpired: isSessionWindowError(raw),
|
|
90
|
+
errorMessage: message ?? `Kapso send failed with status ${res.status}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messageId =
|
|
95
|
+
raw && typeof raw === "object" && "messages" in raw
|
|
96
|
+
? (raw as { messages?: Array<{ id?: string }> }).messages?.[0]?.id
|
|
97
|
+
: undefined;
|
|
98
|
+
return { ok: true, status: res.status, messageId, raw, sessionWindowExpired: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Result of configuring a webhook on a phone number. */
|
|
102
|
+
export interface KapsoWebhookResult {
|
|
103
|
+
ok: boolean;
|
|
104
|
+
status: number;
|
|
105
|
+
raw: unknown;
|
|
106
|
+
errorMessage?: string;
|
|
107
|
+
/** True when an identical webhook already existed and we skipped re-creating it. */
|
|
108
|
+
alreadyRegistered?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Pull a webhook array out of the various shapes Kapso's list endpoint may return. */
|
|
112
|
+
function extractWebhookList(raw: unknown): Array<{ url?: string; kind?: string }> {
|
|
113
|
+
if (Array.isArray(raw)) return raw as Array<{ url?: string; kind?: string }>;
|
|
114
|
+
if (raw && typeof raw === "object") {
|
|
115
|
+
for (const key of ["whatsapp_webhooks", "webhooks", "data"]) {
|
|
116
|
+
const val = (raw as Record<string, unknown>)[key];
|
|
117
|
+
if (Array.isArray(val)) return val as Array<{ url?: string; kind?: string }>;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return true when a Kapso webhook already points at `webhookUrl` for this
|
|
125
|
+
* phone number — used to avoid creating duplicate webhooks on re-registration.
|
|
126
|
+
* Best-effort: returns false if the list endpoint is unavailable.
|
|
127
|
+
*/
|
|
128
|
+
async function kapsoWebhookExists(params: {
|
|
129
|
+
apiBaseUrl: string;
|
|
130
|
+
apiKey: string;
|
|
131
|
+
phoneNumberId: string;
|
|
132
|
+
webhookUrl: string;
|
|
133
|
+
}): Promise<boolean> {
|
|
134
|
+
const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(url, { headers: { "X-API-Key": params.apiKey } });
|
|
137
|
+
if (!res.ok) return false;
|
|
138
|
+
const raw = await parseJsonSafe(res);
|
|
139
|
+
return extractWebhookList(raw).some((w) => w?.url === params.webhookUrl);
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Register (or re-point) the Kapso webhook for a phone number so inbound events
|
|
147
|
+
* are delivered to `webhookUrl`, signed with `secret` via `X-Webhook-Signature`.
|
|
148
|
+
*
|
|
149
|
+
* First checks whether an identical webhook already exists for the number and
|
|
150
|
+
* skips the create call if so, to avoid piling up duplicate webhooks when a
|
|
151
|
+
* number is registered more than once.
|
|
152
|
+
*/
|
|
153
|
+
export async function registerKapsoWebhook(params: {
|
|
154
|
+
apiBaseUrl: string;
|
|
155
|
+
apiKey: string;
|
|
156
|
+
phoneNumberId: string;
|
|
157
|
+
webhookUrl: string;
|
|
158
|
+
secret?: string;
|
|
159
|
+
events?: string[];
|
|
160
|
+
}): Promise<KapsoWebhookResult> {
|
|
161
|
+
const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
await kapsoWebhookExists({
|
|
165
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
166
|
+
apiKey: params.apiKey,
|
|
167
|
+
phoneNumberId: params.phoneNumberId,
|
|
168
|
+
webhookUrl: params.webhookUrl,
|
|
169
|
+
})
|
|
170
|
+
) {
|
|
171
|
+
return { ok: true, status: 200, raw: null, alreadyRegistered: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const whatsapp_webhook: Record<string, unknown> = {
|
|
175
|
+
kind: "kapso",
|
|
176
|
+
url: params.webhookUrl,
|
|
177
|
+
events: params.events ?? ["whatsapp.message.received"],
|
|
178
|
+
};
|
|
179
|
+
if (params.secret) whatsapp_webhook.secret_key = params.secret;
|
|
180
|
+
|
|
181
|
+
const res = await fetch(url, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({ whatsapp_webhook }),
|
|
185
|
+
});
|
|
186
|
+
const raw = await parseJsonSafe(res);
|
|
187
|
+
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const { message } = extractMetaError(raw);
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
status: res.status,
|
|
193
|
+
raw,
|
|
194
|
+
errorMessage: message ?? `Kapso webhook registration failed with status ${res.status}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, status: res.status, raw };
|
|
198
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { deleteKv, getKv, getSwarmConfigs, upsertKv } from "@/be/db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native Kapso/WhatsApp integration — shared server-side config + mapping store.
|
|
5
|
+
*
|
|
6
|
+
* The mapping (phone-number-id → routing target) is backed by the swarm KV store
|
|
7
|
+
* under a pinned namespace, NOT a dedicated table. The inbound webhook handler and
|
|
8
|
+
* the `register-kapso-number` MCP tool are the only readers/writers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Pinned KV namespace for phone-number → routing mappings. No TTL. */
|
|
12
|
+
export const KAPSO_NUMBERS_NAMESPACE = "integrations:kapso:numbers";
|
|
13
|
+
|
|
14
|
+
/** Pinned KV namespace for inbound message-id dedupe markers (24h TTL). */
|
|
15
|
+
export const KAPSO_DEDUPE_NAMESPACE = "integrations:kapso:dedupe";
|
|
16
|
+
|
|
17
|
+
/** How long a dedupe marker lives — long enough to cover Kapso's webhook retries. */
|
|
18
|
+
export const KAPSO_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** Default Kapso API host when `KAPSO_API_BASE_URL` is unset (host only, no path). */
|
|
21
|
+
export const DEFAULT_KAPSO_API_BASE_URL = "https://api.kapso.ai";
|
|
22
|
+
|
|
23
|
+
/** A registered phone number and where its inbound messages should route. */
|
|
24
|
+
export interface KapsoNumberMapping {
|
|
25
|
+
phoneNumberId: string;
|
|
26
|
+
/** Route inbound to this agent as a task. */
|
|
27
|
+
agentId?: string;
|
|
28
|
+
/** Advanced override: dispatch via this workflow's webhook trigger instead of a task. */
|
|
29
|
+
workflowId?: string;
|
|
30
|
+
/** Human-friendly display name for the number. */
|
|
31
|
+
name?: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KapsoConfig {
|
|
36
|
+
apiKey: string | undefined;
|
|
37
|
+
apiBaseUrl: string;
|
|
38
|
+
webhookHmacSecret: string | undefined;
|
|
39
|
+
phoneNumberId: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read a swarm-config value (global scope) by key, falling back to the process
|
|
44
|
+
* env. Decryption happens inside `getSwarmConfigs`.
|
|
45
|
+
*/
|
|
46
|
+
function readConfigValue(key: string): string | undefined {
|
|
47
|
+
const found = getSwarmConfigs({ scope: "global", key }).find(
|
|
48
|
+
(c) => typeof c.value === "string" && c.value.length > 0,
|
|
49
|
+
);
|
|
50
|
+
if (found) return found.value;
|
|
51
|
+
const env = process.env[key];
|
|
52
|
+
return env && env.length > 0 ? env : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the Kapso integration config from swarm config (env fallback). */
|
|
56
|
+
export function getKapsoConfig(): KapsoConfig {
|
|
57
|
+
const base = readConfigValue("KAPSO_API_BASE_URL") ?? DEFAULT_KAPSO_API_BASE_URL;
|
|
58
|
+
return {
|
|
59
|
+
apiKey: readConfigValue("KAPSO_API_KEY"),
|
|
60
|
+
apiBaseUrl: base.replace(/\/+$/, ""),
|
|
61
|
+
webhookHmacSecret: readConfigValue("KAPSO_WEBHOOK_HMAC_SECRET"),
|
|
62
|
+
phoneNumberId: readConfigValue("KAPSO_PHONE_NUMBER_ID"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Look up the routing mapping for a phone-number-id, or null if unregistered. */
|
|
67
|
+
export function getKapsoNumberMapping(phoneNumberId: string): KapsoNumberMapping | null {
|
|
68
|
+
const row = getKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
|
|
69
|
+
return row ? (row.value as KapsoNumberMapping) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Upsert a routing mapping (no TTL). */
|
|
73
|
+
export function putKapsoNumberMapping(mapping: KapsoNumberMapping): KapsoNumberMapping {
|
|
74
|
+
upsertKv({
|
|
75
|
+
namespace: KAPSO_NUMBERS_NAMESPACE,
|
|
76
|
+
key: mapping.phoneNumberId,
|
|
77
|
+
value: mapping,
|
|
78
|
+
valueType: "json",
|
|
79
|
+
expiresAt: null,
|
|
80
|
+
});
|
|
81
|
+
return mapping;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete a routing mapping. Returns true if a row was removed. */
|
|
85
|
+
export function deleteKapsoNumberMapping(phoneNumberId: string): boolean {
|
|
86
|
+
return deleteKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record a message-id as processed. Returns true the FIRST time a given id is
|
|
91
|
+
* seen and false on every subsequent delivery within the TTL window — so the
|
|
92
|
+
* caller drops duplicates (Kapso retries deliveries).
|
|
93
|
+
*/
|
|
94
|
+
export function markKapsoMessageSeen(messageId: string): boolean {
|
|
95
|
+
if (getKv(KAPSO_DEDUPE_NAMESPACE, messageId)) return false;
|
|
96
|
+
upsertKv({
|
|
97
|
+
namespace: KAPSO_DEDUPE_NAMESPACE,
|
|
98
|
+
key: messageId,
|
|
99
|
+
value: 1,
|
|
100
|
+
valueType: "integer",
|
|
101
|
+
expiresAt: Date.now() + KAPSO_DEDUPE_TTL_MS,
|
|
102
|
+
});
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { resolveTemplate } from "@/prompts/resolver";
|
|
2
|
+
import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
|
|
3
|
+
import { workflowEventBus } from "@/workflows/event-bus";
|
|
4
|
+
import "@/tools/templates";
|
|
5
|
+
import { recordUnmappedIdentity } from "@/be/unmapped-identities";
|
|
6
|
+
import { findUserByExternalId } from "@/be/users";
|
|
7
|
+
import { getKapsoNumberMapping, markKapsoMessageSeen } from "./config";
|
|
8
|
+
|
|
9
|
+
const KAPSO_IDENTITY_KIND = "kapso";
|
|
10
|
+
const WHATSAPP_IDENTITY_KIND = "whatsapp";
|
|
11
|
+
|
|
12
|
+
/** Minimal shape of the Kapso v2 inbound webhook payload (see the kapso-whatsapp skill). */
|
|
13
|
+
export interface KapsoWebhookPayload {
|
|
14
|
+
message?: {
|
|
15
|
+
id?: string;
|
|
16
|
+
from?: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
text?: { body?: string };
|
|
19
|
+
kapso?: { direction?: string; content?: string; has_media?: boolean };
|
|
20
|
+
};
|
|
21
|
+
conversation?: {
|
|
22
|
+
id?: string;
|
|
23
|
+
phone_number?: string;
|
|
24
|
+
contact_name?: string;
|
|
25
|
+
};
|
|
26
|
+
phone_number_id?: string;
|
|
27
|
+
test?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Outcome of routing one inbound webhook delivery. */
|
|
31
|
+
export type KapsoRouting =
|
|
32
|
+
| { kind: "skip"; reason: string }
|
|
33
|
+
| { kind: "duplicate"; messageId: string }
|
|
34
|
+
| { kind: "workflow"; workflowId: string }
|
|
35
|
+
| { kind: "task"; taskId: string }
|
|
36
|
+
| { kind: "no_mapping"; phoneNumberId: string };
|
|
37
|
+
|
|
38
|
+
function extractText(message: NonNullable<KapsoWebhookPayload["message"]>): string {
|
|
39
|
+
if (message.text?.body) return message.text.body;
|
|
40
|
+
if (message.kapso?.content) return message.kapso.content;
|
|
41
|
+
return `(non-text message — type: ${message.type ?? "unknown"})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildTaskDescription(payload: KapsoWebhookPayload): string {
|
|
45
|
+
const message = payload.message ?? {};
|
|
46
|
+
const conversation = payload.conversation ?? {};
|
|
47
|
+
return resolveTemplate("kapso.message.received", {
|
|
48
|
+
conversation_id: conversation.id ?? "unknown",
|
|
49
|
+
inbound_wamid: message.id ?? "unknown",
|
|
50
|
+
sender_phone: message.from ?? conversation.phone_number ?? "unknown",
|
|
51
|
+
contact_name: conversation.contact_name ?? "unknown",
|
|
52
|
+
phone_number_id: payload.phone_number_id ?? "unknown",
|
|
53
|
+
test_note: payload.test ? "\n- test: true (do NOT send a real WhatsApp reply)" : "",
|
|
54
|
+
message_text: extractText(message),
|
|
55
|
+
}).text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeKapsoSender(payload: KapsoWebhookPayload): string | null {
|
|
59
|
+
const raw = payload.message?.from ?? payload.conversation?.phone_number ?? "";
|
|
60
|
+
const digits = raw.replace(/\D/g, "");
|
|
61
|
+
return digits || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveKapsoRequestedByUserId(payload: KapsoWebhookPayload): string | undefined {
|
|
65
|
+
const externalId = normalizeKapsoSender(payload);
|
|
66
|
+
if (!externalId) return undefined;
|
|
67
|
+
|
|
68
|
+
const mapped =
|
|
69
|
+
findUserByExternalId(KAPSO_IDENTITY_KIND, externalId) ??
|
|
70
|
+
findUserByExternalId(WHATSAPP_IDENTITY_KIND, externalId);
|
|
71
|
+
if (mapped) return mapped.id;
|
|
72
|
+
|
|
73
|
+
recordUnmappedIdentity(KAPSO_IDENTITY_KIND, externalId, {
|
|
74
|
+
sampleEventType: "kapso.message.received",
|
|
75
|
+
sampleContext: [
|
|
76
|
+
payload.conversation?.contact_name ? `contact=${payload.conversation.contact_name}` : null,
|
|
77
|
+
payload.conversation?.id ? `conversation=${payload.conversation.id}` : null,
|
|
78
|
+
payload.message?.id ? `message=${payload.message.id}` : null,
|
|
79
|
+
payload.phone_number_id ? `phone_number_id=${payload.phone_number_id}` : null,
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(" "),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Route one inbound Kapso webhook delivery. Pure of HTTP concerns — the caller
|
|
90
|
+
* handles HMAC verification and the workflow-trigger dispatch (which needs the
|
|
91
|
+
* raw body + executor registry). This:
|
|
92
|
+
* 1. drops non-inbound events and deliveries missing a message id,
|
|
93
|
+
* 2. dedupes by message id (KV, 24h TTL),
|
|
94
|
+
* 3. emits the `kapso.message.received` workflow event (additive),
|
|
95
|
+
* 4. looks up the phone-number mapping and either signals a workflow dispatch
|
|
96
|
+
* or creates a native `kapso-inbound` task,
|
|
97
|
+
* 5. returns `no_mapping` when the number isn't registered (caller logs a warning).
|
|
98
|
+
*/
|
|
99
|
+
export function routeKapsoInbound(payload: KapsoWebhookPayload): KapsoRouting {
|
|
100
|
+
const message = payload.message;
|
|
101
|
+
const direction = message?.kapso?.direction;
|
|
102
|
+
if (direction !== "inbound") {
|
|
103
|
+
return { kind: "skip", reason: `non_inbound (direction=${direction ?? "none"})` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const messageId = message?.id;
|
|
107
|
+
if (!messageId) {
|
|
108
|
+
return { kind: "skip", reason: "missing_message_id" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!markKapsoMessageSeen(messageId)) {
|
|
112
|
+
return { kind: "duplicate", messageId };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const phoneNumberId = payload.phone_number_id ?? "";
|
|
116
|
+
|
|
117
|
+
// Additive: let event-subscribed workflows observe inbound regardless of mapping.
|
|
118
|
+
workflowEventBus.emit("kapso.message.received", {
|
|
119
|
+
phoneNumberId,
|
|
120
|
+
conversationId: payload.conversation?.id,
|
|
121
|
+
messageId,
|
|
122
|
+
from: message?.from,
|
|
123
|
+
type: message?.type,
|
|
124
|
+
text: extractText(message ?? {}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const mapping = phoneNumberId ? getKapsoNumberMapping(phoneNumberId) : null;
|
|
128
|
+
if (!mapping) {
|
|
129
|
+
return { kind: "no_mapping", phoneNumberId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (mapping.workflowId) {
|
|
133
|
+
return { kind: "workflow", workflowId: mapping.workflowId };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const task = createTaskWithSiblingAwareness(buildTaskDescription(payload), {
|
|
137
|
+
agentId: mapping.agentId ?? null,
|
|
138
|
+
source: "system",
|
|
139
|
+
taskType: "kapso-inbound",
|
|
140
|
+
tags: ["kapso-whatsapp", "inbound"],
|
|
141
|
+
priority: 70,
|
|
142
|
+
requestedByUserId: resolveKapsoRequestedByUserId(payload),
|
|
143
|
+
contextKey: `kapso:conversation:${payload.conversation?.id ?? messageId}`,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return { kind: "task", taskId: task.id };
|
|
147
|
+
}
|
|
@@ -23,6 +23,13 @@ const BOOTSTRAP_TOTAL_MAX_CHARS = 150_000;
|
|
|
23
23
|
const truncationNotice = (file: string) =>
|
|
24
24
|
`\n\n[...truncated, see /workspace/${file} for full content]\n`;
|
|
25
25
|
|
|
26
|
+
export function areSlackPromptToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
27
|
+
const slackDisable = env.SLACK_DISABLE;
|
|
28
|
+
if (slackDisable === "true" || slackDisable === "1") return false;
|
|
29
|
+
|
|
30
|
+
return Boolean(env.SLACK_BOT_TOKEN && env.SLACK_APP_TOKEN);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export type BasePromptArgs = {
|
|
27
34
|
role: string;
|
|
28
35
|
agentId: string;
|
|
@@ -71,9 +78,15 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
|
71
78
|
const compositeResult = await resolveTemplateAsync(compositeEventType, vars);
|
|
72
79
|
let prompt = compositeResult.text;
|
|
73
80
|
|
|
81
|
+
const slackPromptToolsEnabled = areSlackPromptToolsEnabled();
|
|
82
|
+
|
|
83
|
+
if (hasMcp && slackPromptToolsEnabled) {
|
|
84
|
+
const slackResult = await resolveTemplateAsync("system.agent.slack", {});
|
|
85
|
+
prompt += slackResult.text;
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
// Conditionally inject Slack instructions for workers with Slack-originated tasks
|
|
75
|
-
|
|
76
|
-
if (role !== "lead" && args.slackContext && hasMcp) {
|
|
89
|
+
if (role !== "lead" && args.slackContext && hasMcp && slackPromptToolsEnabled) {
|
|
77
90
|
const slackResult = await resolveTemplateAsync("system.agent.worker.slack", {
|
|
78
91
|
slackChannelId: args.slackContext.channelId,
|
|
79
92
|
slackThreadTs: args.slackContext.threadTs ?? "",
|
|
@@ -59,16 +59,11 @@ As the lead agent, you coordinate all worker agents in the swarm.
|
|
|
59
59
|
- \`send-task\`: Assign a task to a specific worker or to the general pool. Slack/AgentMail metadata auto-inherits from parent task.
|
|
60
60
|
- \`store-progress\`: Track coordination notes or update task status
|
|
61
61
|
|
|
62
|
-
**User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the
|
|
63
|
-
|
|
64
|
-
**Slack:**
|
|
65
|
-
- \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
|
|
66
|
-
- \`slack-read\`: Read thread/channel history (use taskId or channelId)
|
|
67
|
-
- \`slack-list-channels\`: Discover available Slack channels
|
|
62
|
+
**User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the task metadata attached to the task.
|
|
68
63
|
|
|
69
64
|
**Identity:**
|
|
70
65
|
- \`update-profile\`: Update your own or other agents' profile fields (name, role, capabilities, soulMd, identityMd, heartbeatMd, claudeMd, toolsMd, setupScript)
|
|
71
|
-
- \`manage-user\`: Register or update human users (resolve from
|
|
66
|
+
- \`manage-user\`: Register or update human users (resolve from GitHub/GitLab identity or other source metadata)
|
|
72
67
|
|
|
73
68
|
#### Task Routing
|
|
74
69
|
|
|
@@ -85,16 +80,14 @@ When composing task descriptions: include the repo URL (if applicable), specific
|
|
|
85
80
|
For follow-up tasks that should continue from previous work, pass \`parentTaskId\` with the previous task's ID:
|
|
86
81
|
- Worker resumes the parent's Claude session (full conversation context preserved)
|
|
87
82
|
- Child task is auto-routed to the same worker (session data is local)
|
|
88
|
-
- Slack metadata (channelId, threadTs, userId) auto-inherits
|
|
89
83
|
|
|
90
84
|
If you explicitly assign to a different worker, session resume gracefully falls back to a fresh session.
|
|
91
85
|
|
|
92
|
-
#### Follow-Up Tasks
|
|
86
|
+
#### Follow-Up Tasks
|
|
93
87
|
|
|
94
88
|
When a worker completes or fails a task, you receive an automatic follow-up task. Handle it by:
|
|
95
89
|
1. Review the output/failure reason
|
|
96
|
-
2.
|
|
97
|
-
3. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
|
|
90
|
+
2. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
|
|
98
91
|
|
|
99
92
|
#### Heartbeat Checklist
|
|
100
93
|
|
|
@@ -106,7 +99,6 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
|
|
|
106
99
|
|
|
107
100
|
**Example standing orders:**
|
|
108
101
|
\`\`\`markdown
|
|
109
|
-
- Check Slack for unaddressed requests older than 1 hour
|
|
110
102
|
- Review active tasks for any that seem stuck or need follow-up
|
|
111
103
|
- If idle workers exist and unassigned tasks are available, investigate why
|
|
112
104
|
\`\`\`
|
|
@@ -122,6 +114,28 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
|
|
|
122
114
|
category: "system",
|
|
123
115
|
});
|
|
124
116
|
|
|
117
|
+
registerTemplate({
|
|
118
|
+
eventType: "system.agent.slack",
|
|
119
|
+
header: "",
|
|
120
|
+
defaultBody: `
|
|
121
|
+
#### Slack Tools
|
|
122
|
+
|
|
123
|
+
- \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
|
|
124
|
+
- \`slack-read\`: Read thread/channel history (use taskId or channelId)
|
|
125
|
+
- \`slack-list-channels\`: Discover available Slack channels
|
|
126
|
+
|
|
127
|
+
**Slack User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`) with Slack metadata, use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the Slack metadata (user ID, display name) attached to the task.
|
|
128
|
+
|
|
129
|
+
**Slack context inheritance:** For follow-up tasks using \`parentTaskId\`, Slack metadata (channelId, threadTs, userId) auto-inherits.
|
|
130
|
+
|
|
131
|
+
**Slack follow-up tasks:** When a worker completes or fails a task that has Slack metadata, use \`slack-reply\` with the task's ID to post the result back to the originating thread before completing the follow-up task.
|
|
132
|
+
|
|
133
|
+
**Slack standing orders:** If you maintain heartbeat standing orders, check Slack for unaddressed requests older than 1 hour when appropriate.
|
|
134
|
+
`,
|
|
135
|
+
variables: [],
|
|
136
|
+
category: "system",
|
|
137
|
+
});
|
|
138
|
+
|
|
125
139
|
registerTemplate({
|
|
126
140
|
eventType: "system.agent.worker",
|
|
127
141
|
header: "",
|
package/src/server.ts
CHANGED
|
@@ -55,6 +55,10 @@ import {
|
|
|
55
55
|
} from "./tools/prompt-templates";
|
|
56
56
|
import { registerReadMessagesTool } from "./tools/read-messages";
|
|
57
57
|
import { registerRegisterAgentmailInboxTool } from "./tools/register-agentmail-inbox";
|
|
58
|
+
import {
|
|
59
|
+
registerRegisterKapsoNumberTool,
|
|
60
|
+
registerUnregisterKapsoNumberTool,
|
|
61
|
+
} from "./tools/register-kapso-number";
|
|
58
62
|
// Services capability
|
|
59
63
|
import { registerRegisterServiceTool } from "./tools/register-service";
|
|
60
64
|
// Repo management tools
|
|
@@ -118,6 +122,10 @@ import { registerUnregisterServiceTool } from "./tools/unregister-service";
|
|
|
118
122
|
// Profiles capability
|
|
119
123
|
import { registerUpdateProfileTool } from "./tools/update-profile";
|
|
120
124
|
import { registerUpdateServiceStatusTool } from "./tools/update-service-status";
|
|
125
|
+
import {
|
|
126
|
+
registerReplyWhatsappMessageTool,
|
|
127
|
+
registerSendWhatsappMessageTool,
|
|
128
|
+
} from "./tools/whatsapp-message";
|
|
121
129
|
// Workflows capability
|
|
122
130
|
import {
|
|
123
131
|
registerCancelWorkflowRunTool,
|
|
@@ -228,6 +236,12 @@ export function createServer() {
|
|
|
228
236
|
// AgentMail integration tool (always registered, self-service inbox mapping)
|
|
229
237
|
registerRegisterAgentmailInboxTool(server);
|
|
230
238
|
|
|
239
|
+
// Kapso/WhatsApp integration tools (native inbound provisioning + outbound)
|
|
240
|
+
registerRegisterKapsoNumberTool(server);
|
|
241
|
+
registerUnregisterKapsoNumberTool(server);
|
|
242
|
+
registerSendWhatsappMessageTool(server);
|
|
243
|
+
registerReplyWhatsappMessageTool(server);
|
|
244
|
+
|
|
231
245
|
// Task pool capability - task pool operations (create unassigned, claim, release, accept, reject)
|
|
232
246
|
if (hasCapability("task-pool")) {
|
|
233
247
|
registerTaskActionTool(server);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const skillPath = `${import.meta.dir}/../../templates/skills/agentmail-sending/SKILL.md`;
|
|
4
|
+
const skill = await Bun.file(skillPath).text();
|
|
5
|
+
const curlInboxVariable = "$" + "{INBOX}";
|
|
6
|
+
const scriptApiKeyVariable = "$" + "{apiKey}";
|
|
7
|
+
|
|
8
|
+
function requireMatch(pattern: RegExp, label: string): RegExpMatchArray {
|
|
9
|
+
const match = skill.match(pattern);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Missing ${label}`);
|
|
12
|
+
}
|
|
13
|
+
return match;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("agentmail-sending skill template", () => {
|
|
17
|
+
test("pins the canonical base URL and rejects the hallucinated host", () => {
|
|
18
|
+
expect(skill).toContain("```text\nhttps://api.agentmail.to/v0/\n```");
|
|
19
|
+
expect(skill).toContain("DO NOT use `api.agentmail.ai`");
|
|
20
|
+
expect(skill).not.toContain("https://api.agentmail.ai");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("pins send-message field names and text-only guidance", () => {
|
|
24
|
+
expect(skill).toContain("```text\nto\nbcc\nsubject\ntext\n```");
|
|
25
|
+
expect(skill).toContain("Use `text`, NOT `text_body`, `body`, or `content`.");
|
|
26
|
+
expect(skill).toContain("Do NOT pass `html`.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("curl example uses the canonical endpoint, bearer auth, and exact JSON fields", () => {
|
|
30
|
+
expect(skill).toContain("https://api.agentmail.to/v0/inboxes/{inbox}/messages/send");
|
|
31
|
+
expect(skill).toContain(
|
|
32
|
+
[
|
|
33
|
+
'curl -sS -X POST "https://api.agentmail.to/v0/inboxes/',
|
|
34
|
+
curlInboxVariable,
|
|
35
|
+
'/messages/send"',
|
|
36
|
+
].join(""),
|
|
37
|
+
);
|
|
38
|
+
expect(skill).toContain('-H "Authorization: Bearer $AGENTMAIL_API_KEY"');
|
|
39
|
+
|
|
40
|
+
const jsonBlock = requireMatch(
|
|
41
|
+
/--data-binary @- <<'JSON'\n([\s\S]*?)\nJSON/,
|
|
42
|
+
"curl JSON body",
|
|
43
|
+
)[1];
|
|
44
|
+
const payload = JSON.parse(jsonBlock);
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(payload)).toEqual(["to", "bcc", "subject", "text"]);
|
|
47
|
+
expect(payload).not.toHaveProperty("text_body");
|
|
48
|
+
expect(payload).not.toHaveProperty("body");
|
|
49
|
+
expect(payload).not.toHaveProperty("content");
|
|
50
|
+
expect(payload).not.toHaveProperty("html");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("script_upsert example uses fetch and resolves AGENTMAIL_API_KEY from swarm config", () => {
|
|
54
|
+
const scriptBlock = requireMatch(/```ts\n([\s\S]*?)\n```/, "script_upsert example")[1];
|
|
55
|
+
|
|
56
|
+
expect(scriptBlock).toContain("await script_upsert({");
|
|
57
|
+
expect(scriptBlock).toContain("ctx.swarm.config.get('AGENTMAIL_API_KEY')");
|
|
58
|
+
expect(scriptBlock).toContain("ctx.stdlib.fetch(");
|
|
59
|
+
expect(scriptBlock).toContain("https://api.agentmail.to/v0/inboxes/");
|
|
60
|
+
expect(scriptBlock).toContain("messages/send");
|
|
61
|
+
expect(scriptBlock).toContain(
|
|
62
|
+
["Authorization: \\`Bearer \\", scriptApiKeyVariable, "\\`"].join(""),
|
|
63
|
+
);
|
|
64
|
+
expect(scriptBlock).toContain("text: args.text");
|
|
65
|
+
expect(scriptBlock).not.toContain("text_body");
|
|
66
|
+
expect(scriptBlock).not.toContain("html:");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("common error table covers known AgentMail mistakes", () => {
|
|
70
|
+
expect(skill).toContain("404 on `/v0/inboxes/.../send`");
|
|
71
|
+
expect(skill).toContain('422 `{"detail":"text Field required"}`');
|
|
72
|
+
expect(skill).toContain("401");
|
|
73
|
+
expect(skill).toContain("HTML rendering bug");
|
|
74
|
+
});
|
|
75
|
+
});
|