@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
@@ -1,6 +1,8 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
3
  import * as z from "zod";
3
4
  import { getAllTasks } from "@/be/db";
5
+ import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
4
6
  import { createToolRegistrar } from "@/tools/utils";
5
7
  import type { AgentTask, AgentTaskSummary } from "@/types";
6
8
  import { AgentTaskStatusSchema } from "@/types";
@@ -24,6 +26,135 @@ const TaskSummarySchema = z.object({
24
26
  progress: z.string().optional(),
25
27
  });
26
28
 
29
+ export const getTasksInputSchema = z.object({
30
+ status: AgentTaskStatusSchema.optional().describe(
31
+ "Filter by task status (unassigned, offered, pending, in_progress, completed, failed).",
32
+ ),
33
+ mineOnly: z.boolean().optional().describe("Only return tasks assigned to you."),
34
+ unassigned: z.boolean().optional().describe("Only return unassigned tasks in the pool."),
35
+ offeredToMe: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Only return tasks offered to you (awaiting accept/reject)."),
39
+ readyOnly: z.boolean().optional().describe("Only return tasks whose dependencies are met."),
40
+ taskType: z.string().optional().describe("Filter by task type (e.g., 'bug', 'feature')."),
41
+ tags: z.array(z.string()).optional().describe("Filter by any matching tag."),
42
+ search: z.string().optional().describe("Search in task description."),
43
+ scheduleId: z
44
+ .string()
45
+ .uuid()
46
+ .optional()
47
+ .describe("Filter by schedule ID to find tasks created by a specific schedule."),
48
+ includeHeartbeat: z
49
+ .boolean()
50
+ .optional()
51
+ .describe("Include heartbeat/system tasks in results (excluded by default)."),
52
+ limit: z
53
+ .number()
54
+ .int()
55
+ .min(1)
56
+ .max(100)
57
+ .optional()
58
+ .describe("Max tasks to return (default: 25, max: 100)."),
59
+ includeFull: z
60
+ .boolean()
61
+ .optional()
62
+ .describe("Return the full `task` text instead of a ~300-char `taskPreview`. Default false."),
63
+ });
64
+
65
+ export const getTasksOutputSchema = z.object({
66
+ yourAgentId: z.string().uuid().optional(),
67
+ tasks: z.array(TaskSummarySchema),
68
+ });
69
+
70
+ type GetTasksArgs = z.infer<typeof getTasksInputSchema>;
71
+
72
+ export async function getTasksHandler(
73
+ ctx: ToolCtx,
74
+ {
75
+ status,
76
+ mineOnly,
77
+ unassigned,
78
+ offeredToMe,
79
+ readyOnly,
80
+ taskType,
81
+ tags,
82
+ search,
83
+ scheduleId,
84
+ includeHeartbeat,
85
+ limit,
86
+ includeFull,
87
+ }: GetTasksArgs,
88
+ ): Promise<CallToolResult> {
89
+ const agentId = ctx.kind === "owner" ? ctx.agentId : undefined;
90
+
91
+ // Build filters. User context is hard-scoped by requestedByUserId and ignores
92
+ // agent-specific shortcuts like mineOnly/offeredToMe.
93
+ const taskFilters = {
94
+ status,
95
+ agentId: ctx.kind === "owner" && mineOnly ? (agentId ?? undefined) : undefined,
96
+ unassigned: ctx.kind === "owner" ? unassigned : undefined,
97
+ offeredTo: ctx.kind === "owner" && offeredToMe ? (agentId ?? undefined) : undefined,
98
+ readyOnly,
99
+ taskType,
100
+ tags,
101
+ search,
102
+ scheduleId,
103
+ includeHeartbeat,
104
+ limit,
105
+ requestedByUserId: ctx.kind === "user" ? ctx.userId : undefined,
106
+ };
107
+ // Default to slim rows (full `task` text → ~300-char `taskPreview`).
108
+ const tasks: Array<AgentTask | AgentTaskSummary> = includeFull
109
+ ? getAllTasks(taskFilters)
110
+ : getAllTasks(taskFilters, { slim: true });
111
+
112
+ // Slim rows carry a truncated `task`; surface it as `taskPreview` so the
113
+ // agent knows it is truncated. `includeFull` returns the full `task`.
114
+ const taskSummaries = tasks.map((t) => ({
115
+ id: t.id,
116
+ agentId: t.agentId,
117
+ ...(includeFull ? { task: t.task } : { taskPreview: t.task }),
118
+ status: t.status,
119
+ taskType: t.taskType,
120
+ tags: t.tags,
121
+ priority: t.priority,
122
+ dependsOn: t.dependsOn,
123
+ offeredTo: t.offeredTo,
124
+ createdAt: t.createdAt,
125
+ lastUpdatedAt: t.lastUpdatedAt,
126
+ finishedAt: t.finishedAt,
127
+ progress: t.progress,
128
+ }));
129
+
130
+ // Build filter description for message
131
+ const filters: string[] = [];
132
+ if (status) filters.push(`status='${status}'`);
133
+ if (ctx.kind === "owner" && mineOnly) filters.push("mine only");
134
+ if (ctx.kind === "owner" && unassigned) filters.push("unassigned");
135
+ if (ctx.kind === "owner" && offeredToMe) filters.push("offered to me");
136
+ if (readyOnly) filters.push("ready only");
137
+ if (taskType) filters.push(`type='${taskType}'`);
138
+ if (tags?.length) filters.push(`tags=[${tags.join(", ")}]`);
139
+ if (search) filters.push(`search='${search}'`);
140
+ if (scheduleId) filters.push(`scheduleId='${scheduleId}'`);
141
+
142
+ const filterMsg = filters.length > 0 ? ` (${filters.join(", ")})` : "";
143
+
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text: `Found ${taskSummaries.length} task(s)${filterMsg}.`,
149
+ },
150
+ ],
151
+ structuredContent: {
152
+ yourAgentId: agentId,
153
+ tasks: taskSummaries,
154
+ },
155
+ };
156
+ }
157
+
27
158
  export const registerGetTasksTool = (server: McpServer) => {
28
159
  createToolRegistrar(server)(
29
160
  "get-tasks",
@@ -32,132 +163,9 @@ export const registerGetTasksTool = (server: McpServer) => {
32
163
  description:
33
164
  "Returns a list of tasks in the swarm with various filters. Sorted by priority (desc) then lastUpdatedAt (desc). Each row carries a `taskPreview` (~300 chars) — enough to pool-triage; pass includeFull:true (or call `get-task-details` by id) for the full `task` text.",
34
165
  annotations: { readOnlyHint: true },
35
-
36
- inputSchema: z.object({
37
- status: AgentTaskStatusSchema.optional().describe(
38
- "Filter by task status (unassigned, offered, pending, in_progress, completed, failed).",
39
- ),
40
- mineOnly: z.boolean().optional().describe("Only return tasks assigned to you."),
41
- unassigned: z.boolean().optional().describe("Only return unassigned tasks in the pool."),
42
- offeredToMe: z
43
- .boolean()
44
- .optional()
45
- .describe("Only return tasks offered to you (awaiting accept/reject)."),
46
- readyOnly: z.boolean().optional().describe("Only return tasks whose dependencies are met."),
47
- taskType: z.string().optional().describe("Filter by task type (e.g., 'bug', 'feature')."),
48
- tags: z.array(z.string()).optional().describe("Filter by any matching tag."),
49
- search: z.string().optional().describe("Search in task description."),
50
- scheduleId: z
51
- .string()
52
- .uuid()
53
- .optional()
54
- .describe("Filter by schedule ID to find tasks created by a specific schedule."),
55
- includeHeartbeat: z
56
- .boolean()
57
- .optional()
58
- .describe("Include heartbeat/system tasks in results (excluded by default)."),
59
- limit: z
60
- .number()
61
- .int()
62
- .min(1)
63
- .max(100)
64
- .optional()
65
- .describe("Max tasks to return (default: 25, max: 100)."),
66
- includeFull: z
67
- .boolean()
68
- .optional()
69
- .describe(
70
- "Return the full `task` text instead of a ~300-char `taskPreview`. Default false.",
71
- ),
72
- }),
73
- outputSchema: z.object({
74
- yourAgentId: z.string().uuid().optional(),
75
- tasks: z.array(TaskSummarySchema),
76
- }),
77
- },
78
- async (
79
- {
80
- status,
81
- mineOnly,
82
- unassigned,
83
- offeredToMe,
84
- readyOnly,
85
- taskType,
86
- tags,
87
- search,
88
- scheduleId,
89
- includeHeartbeat,
90
- limit,
91
- includeFull,
92
- },
93
- requestInfo,
94
- _meta,
95
- ) => {
96
- const agentId = requestInfo.agentId;
97
-
98
- // Build filters
99
- const taskFilters = {
100
- status,
101
- agentId: mineOnly ? (agentId ?? undefined) : undefined,
102
- unassigned,
103
- offeredTo: offeredToMe ? (agentId ?? undefined) : undefined,
104
- readyOnly,
105
- taskType,
106
- tags,
107
- search,
108
- scheduleId,
109
- includeHeartbeat,
110
- limit,
111
- };
112
- // Default to slim rows (full `task` text → ~300-char `taskPreview`).
113
- const tasks: Array<AgentTask | AgentTaskSummary> = includeFull
114
- ? getAllTasks(taskFilters)
115
- : getAllTasks(taskFilters, { slim: true });
116
-
117
- // Slim rows carry a truncated `task`; surface it as `taskPreview` so the
118
- // agent knows it is truncated. `includeFull` returns the full `task`.
119
- const taskSummaries = tasks.map((t) => ({
120
- id: t.id,
121
- agentId: t.agentId,
122
- ...(includeFull ? { task: t.task } : { taskPreview: t.task }),
123
- status: t.status,
124
- taskType: t.taskType,
125
- tags: t.tags,
126
- priority: t.priority,
127
- dependsOn: t.dependsOn,
128
- offeredTo: t.offeredTo,
129
- createdAt: t.createdAt,
130
- lastUpdatedAt: t.lastUpdatedAt,
131
- finishedAt: t.finishedAt,
132
- progress: t.progress,
133
- }));
134
-
135
- // Build filter description for message
136
- const filters: string[] = [];
137
- if (status) filters.push(`status='${status}'`);
138
- if (mineOnly) filters.push("mine only");
139
- if (unassigned) filters.push("unassigned");
140
- if (offeredToMe) filters.push("offered to me");
141
- if (readyOnly) filters.push("ready only");
142
- if (taskType) filters.push(`type='${taskType}'`);
143
- if (tags?.length) filters.push(`tags=[${tags.join(", ")}]`);
144
- if (search) filters.push(`search='${search}'`);
145
- if (scheduleId) filters.push(`scheduleId='${scheduleId}'`);
146
-
147
- const filterMsg = filters.length > 0 ? ` (${filters.join(", ")})` : "";
148
-
149
- return {
150
- content: [
151
- {
152
- type: "text",
153
- text: `Found ${taskSummaries.length} task(s)${filterMsg}.`,
154
- },
155
- ],
156
- structuredContent: {
157
- yourAgentId: agentId,
158
- tasks: taskSummaries,
159
- },
160
- };
166
+ inputSchema: getTasksInputSchema,
167
+ outputSchema: getTasksOutputSchema,
161
168
  },
169
+ async (args, info, _meta) => getTasksHandler(ownerCtx(info), args),
162
170
  );
163
171
  };
@@ -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
+ };