@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,202 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
buildContextPreamble,
|
|
5
|
+
CONTEXT_PREAMBLE_MAX_CHARS,
|
|
6
|
+
CONTEXT_PREAMBLE_MAX_TOKENS,
|
|
7
|
+
fetchTaskContextForPreamble,
|
|
8
|
+
type TaskContextForPreamble,
|
|
9
|
+
} from "../commands/context-preamble";
|
|
10
|
+
|
|
11
|
+
const TEST_PORT = 19091;
|
|
12
|
+
const API_URL = `http://localhost:${TEST_PORT}`;
|
|
13
|
+
const API_KEY = "test-key";
|
|
14
|
+
|
|
15
|
+
// In-memory task store for the mock server
|
|
16
|
+
const mockTasks: Record<string, TaskContextForPreamble> = {};
|
|
17
|
+
|
|
18
|
+
let server: Server;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
server = createServer((req, res) => {
|
|
22
|
+
const url = req.url ?? "";
|
|
23
|
+
const match = url.match(/^\/api\/tasks\/([^/?]+)/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const id = match[1];
|
|
26
|
+
const task = mockTasks[id];
|
|
27
|
+
if (task) {
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(JSON.stringify(task));
|
|
30
|
+
} else {
|
|
31
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.writeHead(404);
|
|
37
|
+
res.end();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, resolve));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
server.close();
|
|
45
|
+
// Clear mocks
|
|
46
|
+
for (const k of Object.keys(mockTasks)) delete mockTasks[k];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function seedTask(task: TaskContextForPreamble): void {
|
|
50
|
+
mockTasks[task.id] = task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("fetchTaskContextForPreamble", () => {
|
|
54
|
+
test("returns null on 404", async () => {
|
|
55
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "missing-id");
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("fetches task context fields", async () => {
|
|
60
|
+
seedTask({
|
|
61
|
+
id: "task-a",
|
|
62
|
+
task: "Build the widget",
|
|
63
|
+
output: "Widget built successfully",
|
|
64
|
+
status: "completed",
|
|
65
|
+
attachments: [{ kind: "url", name: "Report", url: "https://example.com/report" }],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "task-a");
|
|
69
|
+
expect(result).not.toBeNull();
|
|
70
|
+
expect(result?.id).toBe("task-a");
|
|
71
|
+
expect(result?.task).toBe("Build the widget");
|
|
72
|
+
expect(result?.output).toBe("Widget built successfully");
|
|
73
|
+
expect(result?.attachments).toHaveLength(1);
|
|
74
|
+
expect(result?.attachments?.[0].name).toBe("Report");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("buildContextPreamble", () => {
|
|
79
|
+
test("returns null when parent task not found", async () => {
|
|
80
|
+
const result = await buildContextPreamble(API_URL, API_KEY, "nonexistent-parent");
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("includes parent task subject and output in preamble", async () => {
|
|
85
|
+
seedTask({
|
|
86
|
+
id: "parent-1",
|
|
87
|
+
task: "Fix the auth bug in login flow",
|
|
88
|
+
output: "Fixed by patching jwt validation in auth.ts:42",
|
|
89
|
+
status: "completed",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-1");
|
|
93
|
+
expect(preamble).not.toBeNull();
|
|
94
|
+
expect(preamble).toContain("parent-1");
|
|
95
|
+
expect(preamble).toContain("Fix the auth bug in login flow");
|
|
96
|
+
expect(preamble).toContain("Fixed by patching jwt validation");
|
|
97
|
+
expect(preamble).toContain("get-task-details");
|
|
98
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("includes attachment pointers in preamble", async () => {
|
|
102
|
+
seedTask({
|
|
103
|
+
id: "parent-2",
|
|
104
|
+
task: "Generate a report",
|
|
105
|
+
output: "Report generated",
|
|
106
|
+
status: "completed",
|
|
107
|
+
attachments: [
|
|
108
|
+
{ kind: "url", name: "Final Report", url: "https://example.com/report.pdf" },
|
|
109
|
+
{
|
|
110
|
+
kind: "agent-fs",
|
|
111
|
+
name: "Raw Data",
|
|
112
|
+
path: "thoughts/agent/research/data.md",
|
|
113
|
+
orgId: "org-123",
|
|
114
|
+
driveId: "drv-456",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-2");
|
|
120
|
+
expect(preamble).toContain("Final Report");
|
|
121
|
+
expect(preamble).toContain("https://example.com/report.pdf");
|
|
122
|
+
expect(preamble).toContain("Raw Data");
|
|
123
|
+
expect(preamble).toContain("live.agent-fs.dev");
|
|
124
|
+
expect(preamble).toContain("org-123");
|
|
125
|
+
expect(preamble).toContain("drv-456");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("shows 'no output recorded' when task has no output or progress", async () => {
|
|
129
|
+
seedTask({
|
|
130
|
+
id: "parent-no-output",
|
|
131
|
+
task: "A task with no output yet",
|
|
132
|
+
status: "in_progress",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-no-output");
|
|
136
|
+
expect(preamble).toContain("no output recorded");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("walks ancestor chain and includes older ancestors as pointers", async () => {
|
|
140
|
+
seedTask({
|
|
141
|
+
id: "grandparent-1",
|
|
142
|
+
task: "Initial research task",
|
|
143
|
+
output: "Research complete",
|
|
144
|
+
status: "completed",
|
|
145
|
+
});
|
|
146
|
+
seedTask({
|
|
147
|
+
id: "child-of-grandparent",
|
|
148
|
+
task: "Second task referencing research",
|
|
149
|
+
output: "Second task done",
|
|
150
|
+
status: "completed",
|
|
151
|
+
parentTaskId: "grandparent-1",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "child-of-grandparent");
|
|
155
|
+
expect(preamble).not.toBeNull();
|
|
156
|
+
// Immediate parent (child-of-grandparent) gets inline detail
|
|
157
|
+
expect(preamble).toContain("child-of-grandparent");
|
|
158
|
+
expect(preamble).toContain("Second task done");
|
|
159
|
+
// Grandparent gets pointer-only entry
|
|
160
|
+
expect(preamble).toContain("grandparent-1");
|
|
161
|
+
expect(preamble).toContain("Older Ancestor Tasks");
|
|
162
|
+
expect(preamble).toContain("Initial research task");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("enforces token budget — truncates oversized output", async () => {
|
|
166
|
+
// Generate output that exceeds the budget
|
|
167
|
+
const hugeOutput = "x".repeat(CONTEXT_PREAMBLE_MAX_CHARS + 5000);
|
|
168
|
+
seedTask({
|
|
169
|
+
id: "parent-big",
|
|
170
|
+
task: "Task with very large output",
|
|
171
|
+
output: hugeOutput,
|
|
172
|
+
status: "completed",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-big");
|
|
176
|
+
expect(preamble).not.toBeNull();
|
|
177
|
+
// Preamble must be within budget (some slack for the truncation suffix)
|
|
178
|
+
expect(preamble?.length ?? 0).toBeLessThanOrEqual(
|
|
179
|
+
CONTEXT_PREAMBLE_MAX_CHARS + 300, // 300 chars slack for the truncation message
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("preamble starts with context section and ends with separator", async () => {
|
|
184
|
+
seedTask({
|
|
185
|
+
id: "parent-structure",
|
|
186
|
+
task: "A well-structured task",
|
|
187
|
+
output: "Done",
|
|
188
|
+
status: "completed",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-structure");
|
|
192
|
+
expect(preamble).toContain("---");
|
|
193
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
194
|
+
// Should end with trailing separator
|
|
195
|
+
expect(preamble?.trimEnd()).toMatch(/---\s*$/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("CONTEXT_PREAMBLE_MAX_TOKENS is 2000 by default", () => {
|
|
199
|
+
expect(CONTEXT_PREAMBLE_MAX_TOKENS).toBe(2000);
|
|
200
|
+
expect(CONTEXT_PREAMBLE_MAX_CHARS).toBe(8000);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -323,9 +323,10 @@ describe("Tool Annotations & Classification", () => {
|
|
|
323
323
|
test("registered tool count matches expected total", () => {
|
|
324
324
|
const count = Object.keys(tools).length;
|
|
325
325
|
// We expect all tools to be registered when all capabilities are enabled (default)
|
|
326
|
-
// Includes 11 skill tools, 7 MCP server tools,
|
|
326
|
+
// Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
|
|
327
|
+
// native Kapso/WhatsApp tools (register/unregister number + send/reply message).
|
|
327
328
|
expect(count).toBeGreaterThanOrEqual(45);
|
|
328
|
-
expect(count).toBeLessThanOrEqual(
|
|
329
|
+
expect(count).toBeLessThanOrEqual(120);
|
|
329
330
|
});
|
|
330
331
|
|
|
331
332
|
test("core tools are fewer than deferred tools", () => {
|
package/src/tools/cancel-task.ts
CHANGED
|
@@ -142,12 +142,20 @@ export async function cancelTaskHandler(
|
|
|
142
142
|
|
|
143
143
|
if ("content" in result) return result;
|
|
144
144
|
|
|
145
|
+
const structuredContent = {
|
|
146
|
+
yourAgentId: agentId,
|
|
147
|
+
...result,
|
|
148
|
+
};
|
|
149
|
+
|
|
145
150
|
return {
|
|
146
|
-
content: [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text", text: result.message },
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: JSON.stringify(structuredContent),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
structuredContent,
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|
|
@@ -65,17 +65,25 @@ export async function getTaskDetailsHandler(
|
|
|
65
65
|
? { name: requestedByUser.name, email: requestedByUser.email }
|
|
66
66
|
: undefined;
|
|
67
67
|
|
|
68
|
+
const structuredContent = {
|
|
69
|
+
yourAgentId: agentId,
|
|
70
|
+
success: true,
|
|
71
|
+
message: `Task "${taskId}" details retrieved.`,
|
|
72
|
+
task,
|
|
73
|
+
requestedBy,
|
|
74
|
+
logs,
|
|
75
|
+
attachments,
|
|
76
|
+
};
|
|
77
|
+
|
|
68
78
|
return {
|
|
69
|
-
content: [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
attachments,
|
|
78
|
-
},
|
|
79
|
+
content: [
|
|
80
|
+
{ type: "text", text: `Task "${taskId}" details retrieved.` },
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify(structuredContent),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
structuredContent,
|
|
79
87
|
};
|
|
80
88
|
}
|
|
81
89
|
|
package/src/tools/get-tasks.ts
CHANGED
|
@@ -140,6 +140,10 @@ export async function getTasksHandler(
|
|
|
140
140
|
if (scheduleId) filters.push(`scheduleId='${scheduleId}'`);
|
|
141
141
|
|
|
142
142
|
const filterMsg = filters.length > 0 ? ` (${filters.join(", ")})` : "";
|
|
143
|
+
const structuredContent = {
|
|
144
|
+
yourAgentId: agentId,
|
|
145
|
+
tasks: taskSummaries,
|
|
146
|
+
};
|
|
143
147
|
|
|
144
148
|
return {
|
|
145
149
|
content: [
|
|
@@ -147,11 +151,12 @@ export async function getTasksHandler(
|
|
|
147
151
|
type: "text",
|
|
148
152
|
text: `Found ${taskSummaries.length} task(s)${filterMsg}.`,
|
|
149
153
|
},
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: JSON.stringify(structuredContent),
|
|
157
|
+
},
|
|
150
158
|
],
|
|
151
|
-
structuredContent
|
|
152
|
-
yourAgentId: agentId,
|
|
153
|
-
tasks: taskSummaries,
|
|
154
|
-
},
|
|
159
|
+
structuredContent,
|
|
155
160
|
};
|
|
156
161
|
}
|
|
157
162
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getAgentById, getLeadAgent } from "@/be/db";
|
|
4
|
+
import { registerKapsoWebhook } from "@/integrations/kapso/client";
|
|
5
|
+
import {
|
|
6
|
+
deleteKapsoNumberMapping,
|
|
7
|
+
getKapsoConfig,
|
|
8
|
+
getKapsoNumberMapping,
|
|
9
|
+
type KapsoNumberMapping,
|
|
10
|
+
putKapsoNumberMapping,
|
|
11
|
+
} from "@/integrations/kapso/config";
|
|
12
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
13
|
+
|
|
14
|
+
/** Build the native inbound webhook URL the swarm exposes for Kapso deliveries. */
|
|
15
|
+
function nativeWebhookUrl(): string {
|
|
16
|
+
const base = (
|
|
17
|
+
process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`
|
|
18
|
+
).replace(/\/+$/, "");
|
|
19
|
+
return `${base}/api/integrations/kapso/webhook`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const registerRegisterKapsoNumberTool = (server: McpServer) => {
|
|
23
|
+
createToolRegistrar(server)(
|
|
24
|
+
"register-kapso-number",
|
|
25
|
+
{
|
|
26
|
+
title: "Register Kapso WhatsApp Number",
|
|
27
|
+
annotations: { idempotentHint: true, openWorldHint: true },
|
|
28
|
+
description:
|
|
29
|
+
"Provision a Kapso WhatsApp phone number for native inbound routing. Lead-only. Points the number's Kapso webhook at the swarm's native handler (signed with KAPSO_WEBHOOK_HMAC_SECRET) and stores a KV mapping so inbound messages route to an agent (defaults to the lead, or a workflow if workflowId is given). Returns the stored mapping + the registered webhook URL.",
|
|
30
|
+
inputSchema: z.object({
|
|
31
|
+
phoneNumberId: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe("Kapso/Meta phone-number ID to provision (KAPSO_PHONE_NUMBER_ID)."),
|
|
35
|
+
agentId: z
|
|
36
|
+
.string()
|
|
37
|
+
.uuid()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
"Agent to route inbound messages to as a `kapso-inbound` task. Defaults to the lead agent when omitted.",
|
|
41
|
+
),
|
|
42
|
+
workflowId: z
|
|
43
|
+
.string()
|
|
44
|
+
.uuid()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe(
|
|
47
|
+
"Advanced override: dispatch inbound via this workflow's webhook trigger instead of a task.",
|
|
48
|
+
),
|
|
49
|
+
name: z.string().optional().describe("Human-friendly display name for the number."),
|
|
50
|
+
}),
|
|
51
|
+
outputSchema: z.object({
|
|
52
|
+
yourAgentId: z.string().uuid().optional(),
|
|
53
|
+
success: z.boolean(),
|
|
54
|
+
message: z.string(),
|
|
55
|
+
webhookUrl: z.string().optional(),
|
|
56
|
+
webhookRegistered: z.boolean().optional(),
|
|
57
|
+
mapping: z
|
|
58
|
+
.object({
|
|
59
|
+
phoneNumberId: z.string(),
|
|
60
|
+
agentId: z.string().optional(),
|
|
61
|
+
workflowId: z.string().optional(),
|
|
62
|
+
name: z.string().optional(),
|
|
63
|
+
createdAt: z.string(),
|
|
64
|
+
})
|
|
65
|
+
.optional(),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
async ({ phoneNumberId, agentId, workflowId, name }, requestInfo) => {
|
|
69
|
+
try {
|
|
70
|
+
// Lead-only: provisioning a number rewires inbound routing for the
|
|
71
|
+
// whole swarm, so restrict it to the lead agent.
|
|
72
|
+
const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
|
|
73
|
+
if (!callerAgent?.isLead) {
|
|
74
|
+
const msg = "Permission denied. Only the lead can register a Kapso number.";
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: msg }],
|
|
77
|
+
structuredContent: { yourAgentId: requestInfo.agentId, success: false, message: msg },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Default the routing target to the lead when no agent/workflow is given.
|
|
82
|
+
const ownerAgentId = agentId ?? (workflowId ? undefined : getLeadAgent()?.id);
|
|
83
|
+
|
|
84
|
+
const config = getKapsoConfig();
|
|
85
|
+
const webhookUrl = nativeWebhookUrl();
|
|
86
|
+
|
|
87
|
+
// Best-effort: point the Kapso webhook at our native handler. The KV
|
|
88
|
+
// mapping (the durable routing record the inbound handler reads) is
|
|
89
|
+
// written regardless, so a manually-configured number still routes.
|
|
90
|
+
let webhookRegistered = false;
|
|
91
|
+
let webhookNote = "";
|
|
92
|
+
if (!config.apiKey) {
|
|
93
|
+
webhookNote =
|
|
94
|
+
" (KAPSO_API_KEY not configured — skipped provider webhook registration; configure the webhook in the Kapso dashboard)";
|
|
95
|
+
} else {
|
|
96
|
+
const result = await registerKapsoWebhook({
|
|
97
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
98
|
+
apiKey: config.apiKey,
|
|
99
|
+
phoneNumberId,
|
|
100
|
+
webhookUrl,
|
|
101
|
+
secret: config.webhookHmacSecret,
|
|
102
|
+
});
|
|
103
|
+
webhookRegistered = result.ok;
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
webhookNote = ` (provider webhook registration failed: ${result.errorMessage})`;
|
|
106
|
+
} else if (result.alreadyRegistered) {
|
|
107
|
+
webhookNote = " (webhook already registered — skipped re-creation)";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const mapping: KapsoNumberMapping = {
|
|
112
|
+
phoneNumberId,
|
|
113
|
+
...(ownerAgentId ? { agentId: ownerAgentId } : {}),
|
|
114
|
+
...(workflowId ? { workflowId } : {}),
|
|
115
|
+
...(name ? { name } : {}),
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
putKapsoNumberMapping(mapping);
|
|
119
|
+
|
|
120
|
+
const text = `Registered Kapso number ${phoneNumberId} → ${
|
|
121
|
+
workflowId
|
|
122
|
+
? `workflow ${workflowId}`
|
|
123
|
+
: ownerAgentId
|
|
124
|
+
? `agent ${ownerAgentId}`
|
|
125
|
+
: "task pool"
|
|
126
|
+
}${webhookNote}`;
|
|
127
|
+
return {
|
|
128
|
+
content: [{ type: "text", text }],
|
|
129
|
+
structuredContent: {
|
|
130
|
+
yourAgentId: requestInfo.agentId,
|
|
131
|
+
success: true,
|
|
132
|
+
message: text,
|
|
133
|
+
webhookUrl,
|
|
134
|
+
webhookRegistered,
|
|
135
|
+
mapping,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: `Error: ${errorMessage}` }],
|
|
142
|
+
structuredContent: {
|
|
143
|
+
yourAgentId: requestInfo.agentId,
|
|
144
|
+
success: false,
|
|
145
|
+
message: errorMessage,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const registerUnregisterKapsoNumberTool = (server: McpServer) => {
|
|
154
|
+
createToolRegistrar(server)(
|
|
155
|
+
"unregister-kapso-number",
|
|
156
|
+
{
|
|
157
|
+
title: "Unregister Kapso WhatsApp Number",
|
|
158
|
+
annotations: { idempotentHint: true },
|
|
159
|
+
description:
|
|
160
|
+
"Remove a Kapso phone number's native routing mapping from the KV store. Lead-only. Inbound messages for the number stop routing through the native handler. The Kapso-side webhook is not deleted automatically — remove it in the Kapso dashboard if you want deliveries to stop.",
|
|
161
|
+
inputSchema: z.object({
|
|
162
|
+
phoneNumberId: z
|
|
163
|
+
.string()
|
|
164
|
+
.min(1)
|
|
165
|
+
.describe("Kapso/Meta phone-number ID whose mapping should be removed."),
|
|
166
|
+
}),
|
|
167
|
+
outputSchema: z.object({
|
|
168
|
+
yourAgentId: z.string().uuid().optional(),
|
|
169
|
+
success: z.boolean(),
|
|
170
|
+
message: z.string(),
|
|
171
|
+
}),
|
|
172
|
+
},
|
|
173
|
+
async ({ phoneNumberId }, requestInfo) => {
|
|
174
|
+
try {
|
|
175
|
+
const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
|
|
176
|
+
if (!callerAgent?.isLead) {
|
|
177
|
+
const msg = "Permission denied. Only the lead can unregister a Kapso number.";
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text: msg }],
|
|
180
|
+
structuredContent: { yourAgentId: requestInfo.agentId, success: false, message: msg },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const existing = getKapsoNumberMapping(phoneNumberId);
|
|
185
|
+
const deleted = deleteKapsoNumberMapping(phoneNumberId);
|
|
186
|
+
const text = existing
|
|
187
|
+
? `Unregistered Kapso number ${phoneNumberId}`
|
|
188
|
+
: `No mapping found for Kapso number ${phoneNumberId}`;
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text }],
|
|
191
|
+
structuredContent: {
|
|
192
|
+
yourAgentId: requestInfo.agentId,
|
|
193
|
+
success: deleted,
|
|
194
|
+
message: text,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: "text", text: `Error: ${errorMessage}` }],
|
|
201
|
+
structuredContent: {
|
|
202
|
+
yourAgentId: requestInfo.agentId,
|
|
203
|
+
success: false,
|
|
204
|
+
message: errorMessage,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
};
|
package/src/tools/send-task.ts
CHANGED
|
@@ -326,13 +326,17 @@ export async function sendTaskHandler(
|
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
const result = txn();
|
|
329
|
+
const structuredContent = {
|
|
330
|
+
yourAgentId: creatorAgentId,
|
|
331
|
+
...result,
|
|
332
|
+
};
|
|
329
333
|
|
|
330
334
|
return {
|
|
331
|
-
content: [
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
content: [
|
|
336
|
+
{ type: "text", text: result.message },
|
|
337
|
+
{ type: "text", text: JSON.stringify(result) },
|
|
338
|
+
],
|
|
339
|
+
structuredContent,
|
|
336
340
|
};
|
|
337
341
|
}
|
|
338
342
|
|
package/src/tools/task-action.ts
CHANGED
|
@@ -108,24 +108,34 @@ type TaskActionResult = {
|
|
|
108
108
|
|
|
109
109
|
function agentOnlyActionResult(): CallToolResult {
|
|
110
110
|
const message = "This action is only available to worker agents.";
|
|
111
|
+
const structuredContent = {
|
|
112
|
+
success: false,
|
|
113
|
+
message,
|
|
114
|
+
};
|
|
115
|
+
|
|
111
116
|
return {
|
|
112
117
|
isError: true,
|
|
113
|
-
content: [
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
content: [
|
|
119
|
+
{ type: "text", text: message },
|
|
120
|
+
{ type: "text", text: JSON.stringify(structuredContent) },
|
|
121
|
+
],
|
|
122
|
+
structuredContent,
|
|
118
123
|
};
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
function taskActionCallResult(result: TaskActionResult, agentId?: string): CallToolResult {
|
|
122
127
|
const { refusalSideEffects: _omit, ...publicResult } = result;
|
|
128
|
+
const structuredContent = {
|
|
129
|
+
yourAgentId: agentId,
|
|
130
|
+
...publicResult,
|
|
131
|
+
};
|
|
132
|
+
|
|
123
133
|
return {
|
|
124
|
-
content: [
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
content: [
|
|
135
|
+
{ type: "text", text: result.message },
|
|
136
|
+
{ type: "text", text: JSON.stringify(structuredContent) },
|
|
137
|
+
],
|
|
138
|
+
structuredContent,
|
|
129
139
|
};
|
|
130
140
|
}
|
|
131
141
|
|
package/src/tools/templates.ts
CHANGED
|
@@ -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
|
// ============================================================================
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -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",
|