@desplega.ai/agent-swarm 1.69.0 → 1.70.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 (44) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +62 -1
  3. package/package.json +1 -1
  4. package/src/agentmail/handlers.ts +87 -6
  5. package/src/be/db.ts +34 -2
  6. package/src/be/migrations/042_task_context_key.sql +13 -0
  7. package/src/commands/runner.ts +1 -0
  8. package/src/github/handlers.ts +42 -10
  9. package/src/gitlab/handlers.ts +29 -5
  10. package/src/hooks/hook.ts +4 -2
  11. package/src/http/core.ts +36 -26
  12. package/src/http/mcp-oauth.ts +132 -60
  13. package/src/http/mcp-servers.ts +5 -1
  14. package/src/http/schedules.ts +4 -2
  15. package/src/http/tasks.ts +4 -2
  16. package/src/linear/sync.ts +22 -10
  17. package/src/providers/claude-adapter.ts +51 -29
  18. package/src/scheduler/scheduler.ts +9 -10
  19. package/src/server.ts +2 -0
  20. package/src/slack/actions.ts +10 -9
  21. package/src/slack/assistant.ts +8 -4
  22. package/src/slack/handlers.ts +8 -3
  23. package/src/slack/thread-buffer.ts +61 -72
  24. package/src/tasks/additive-buffer.ts +152 -0
  25. package/src/tasks/additive-ingress.ts +125 -0
  26. package/src/tasks/context-key.ts +245 -0
  27. package/src/tasks/sibling-awareness.ts +144 -0
  28. package/src/tasks/sibling-block.ts +164 -0
  29. package/src/tests/additive-buffer.test.ts +186 -0
  30. package/src/tests/additive-ingress.test.ts +111 -0
  31. package/src/tests/claude-adapter.test.ts +143 -1
  32. package/src/tests/context-key-db.test.ts +87 -0
  33. package/src/tests/context-key.test.ts +173 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  36. package/src/tests/sibling-awareness-db.test.ts +172 -0
  37. package/src/tests/sibling-block.test.ts +232 -0
  38. package/src/tests/tool-annotations.test.ts +1 -0
  39. package/src/tools/slack-post.ts +10 -3
  40. package/src/tools/slack-start-thread.ts +123 -0
  41. package/src/tools/tool-config.ts +2 -1
  42. package/src/tools/update-profile.ts +5 -2
  43. package/src/types.ts +5 -0
  44. package/src/workflows/executors/agent-task.ts +21 -14
@@ -0,0 +1,232 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ formatRelativeTime,
4
+ pickResumeParent,
5
+ prependSiblingBlock,
6
+ renderSiblingBlock,
7
+ type SiblingTaskInfo,
8
+ stripSiblingBlock,
9
+ truncateForBlock,
10
+ } from "../tasks/sibling-block";
11
+
12
+ const NOW = Date.parse("2026-04-22T12:00:00.000Z");
13
+
14
+ function sibling(overrides: Partial<SiblingTaskInfo> = {}): SiblingTaskInfo {
15
+ return {
16
+ id: "00000000-0000-0000-0000-000000000001",
17
+ status: "in_progress",
18
+ agentId: "agent-A",
19
+ agentName: "Picateclas",
20
+ description: "Do the thing",
21
+ updatedAt: new Date(NOW - 60_000).toISOString(),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("truncateForBlock", () => {
27
+ test("returns string unchanged when under limit", () => {
28
+ expect(truncateForBlock("hello world")).toBe("hello world");
29
+ });
30
+
31
+ test("collapses internal whitespace", () => {
32
+ expect(truncateForBlock("hello\n\n world\t!")).toBe("hello world !");
33
+ });
34
+
35
+ test("truncates to max length and appends ellipsis", () => {
36
+ const long = "a".repeat(250);
37
+ const out = truncateForBlock(long, 200);
38
+ expect(out.endsWith("…")).toBe(true);
39
+ expect(out.length).toBeLessThanOrEqual(201);
40
+ });
41
+
42
+ test("handles non-string input gracefully", () => {
43
+ // @ts-expect-error — runtime safety
44
+ expect(truncateForBlock(undefined)).toBe("");
45
+ // @ts-expect-error — runtime safety
46
+ expect(truncateForBlock(null)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("formatRelativeTime", () => {
51
+ test("seconds", () => {
52
+ expect(formatRelativeTime(NOW - 30_000, NOW)).toBe("30s");
53
+ });
54
+ test("minutes", () => {
55
+ expect(formatRelativeTime(NOW - 5 * 60_000, NOW)).toBe("5m");
56
+ });
57
+ test("hours", () => {
58
+ expect(formatRelativeTime(NOW - 3 * 3600_000, NOW)).toBe("3h");
59
+ });
60
+ test("days", () => {
61
+ expect(formatRelativeTime(NOW - 2 * 86_400_000, NOW)).toBe("2d");
62
+ });
63
+ test("treats future timestamps as 0s, not negative", () => {
64
+ expect(formatRelativeTime(NOW + 60_000, NOW)).toBe("0s");
65
+ });
66
+ test("returns 'unknown time' for unparseable input", () => {
67
+ expect(formatRelativeTime("not a date", NOW)).toBe("unknown time");
68
+ });
69
+ });
70
+
71
+ describe("renderSiblingBlock", () => {
72
+ test("returns empty string when there are no siblings", () => {
73
+ expect(renderSiblingBlock("task:slack:C1:1", [], NOW)).toBe("");
74
+ });
75
+
76
+ test("renders one sibling with all expected fields", () => {
77
+ const out = renderSiblingBlock("task:slack:C1:1", [sibling()], NOW);
78
+ expect(out).toContain("<sibling_tasks_in_progress>");
79
+ expect(out).toContain("</sibling_tasks_in_progress>");
80
+ expect(out).toContain("contextKey: task:slack:C1:1");
81
+ expect(out).toContain("[in_progress] task:00000000-0000-0000-0000-000000000001");
82
+ expect(out).toContain("agent:Picateclas");
83
+ expect(out).toContain("started 1m ago");
84
+ expect(out).toContain('"Do the thing"');
85
+ });
86
+
87
+ test("renders multiple siblings as separate bullets", () => {
88
+ const out = renderSiblingBlock(
89
+ "task:slack:C1:1",
90
+ [
91
+ sibling({ id: "id-1", description: "first" }),
92
+ sibling({
93
+ id: "id-2",
94
+ status: "pending",
95
+ description: "second",
96
+ updatedAt: new Date(NOW - 3600_000).toISOString(),
97
+ }),
98
+ ],
99
+ NOW,
100
+ );
101
+ expect(out).toContain("[in_progress] task:id-1");
102
+ expect(out).toContain("[pending] task:id-2");
103
+ expect(out).toContain("started 1h ago");
104
+ });
105
+
106
+ test("falls back to agent:unassigned when no agent info", () => {
107
+ const out = renderSiblingBlock(
108
+ "task:slack:C1:1",
109
+ [sibling({ agentId: null, agentName: null })],
110
+ NOW,
111
+ );
112
+ expect(out).toContain("agent:unassigned");
113
+ });
114
+
115
+ test("falls back to agentId when agentName missing", () => {
116
+ const out = renderSiblingBlock(
117
+ "task:slack:C1:1",
118
+ [sibling({ agentName: null, agentId: "agent-XYZ" })],
119
+ NOW,
120
+ );
121
+ expect(out).toContain("agent:agent-XYZ");
122
+ });
123
+
124
+ test("truncates long descriptions to 200 chars + ellipsis", () => {
125
+ const long = "x".repeat(500);
126
+ const out = renderSiblingBlock("task:slack:C1:1", [sibling({ description: long })], NOW);
127
+ expect(out).toContain("…");
128
+ // Per-description line: bullet has the truncated description in quotes.
129
+ const descLine = out.split("\n").find((l) => l.includes('"x'));
130
+ expect(descLine).toBeDefined();
131
+ expect((descLine as string).length).toBeLessThan(220);
132
+ });
133
+ });
134
+
135
+ describe("prependSiblingBlock", () => {
136
+ test("prepends the block with a blank line separator", () => {
137
+ const out = prependSiblingBlock("Original task body", "task:slack:C1:1", [sibling()], NOW);
138
+ expect(out.startsWith("<sibling_tasks_in_progress>")).toBe(true);
139
+ expect(out.endsWith("Original task body")).toBe(true);
140
+ expect(out).toContain("</sibling_tasks_in_progress>\n\nOriginal task body");
141
+ });
142
+
143
+ test("returns the description unchanged when there are no siblings", () => {
144
+ expect(prependSiblingBlock("Original", "task:slack:C1:1", [], NOW)).toBe("Original");
145
+ });
146
+ });
147
+
148
+ describe("stripSiblingBlock", () => {
149
+ test("returns description unchanged when no block", () => {
150
+ expect(stripSiblingBlock("Just a body")).toBe("Just a body");
151
+ });
152
+
153
+ test("removes a prepended block and its separator", () => {
154
+ const withBlock = prependSiblingBlock("Real body", "task:slack:C1:1", [sibling()], NOW);
155
+ expect(stripSiblingBlock(withBlock)).toBe("Real body");
156
+ });
157
+
158
+ test("removes a block that appears mid-description", () => {
159
+ const block = renderSiblingBlock("task:slack:C1:1", [sibling()], NOW);
160
+ const mixed = `Prelude\n\n${block}\n\nAfter`;
161
+ expect(stripSiblingBlock(mixed)).toBe("Prelude\n\nAfter");
162
+ });
163
+
164
+ test("returns input unchanged when tags are unmatched", () => {
165
+ const broken = "<sibling_tasks_in_progress> no closing tag here";
166
+ expect(stripSiblingBlock(broken)).toBe(broken);
167
+ });
168
+
169
+ test("handles non-string input", () => {
170
+ // @ts-expect-error — runtime safety
171
+ expect(stripSiblingBlock(undefined)).toBe("");
172
+ });
173
+ });
174
+
175
+ describe("pickResumeParent", () => {
176
+ test("returns null when currentAgentId is missing", () => {
177
+ expect(pickResumeParent([sibling()], null)).toBeNull();
178
+ expect(pickResumeParent([sibling()], undefined)).toBeNull();
179
+ });
180
+
181
+ test("returns null when no siblings are on the same agent", () => {
182
+ const out = pickResumeParent([sibling({ agentId: "agent-B" })], "agent-A");
183
+ expect(out).toBeNull();
184
+ });
185
+
186
+ test("returns null for empty input", () => {
187
+ expect(pickResumeParent([], "agent-A")).toBeNull();
188
+ });
189
+
190
+ test("in_progress beats pending even if pending is more recent", () => {
191
+ const recent_pending = sibling({
192
+ id: "rp",
193
+ status: "pending",
194
+ updatedAt: new Date(NOW).toISOString(),
195
+ });
196
+ const older_in_progress = sibling({
197
+ id: "oip",
198
+ status: "in_progress",
199
+ updatedAt: new Date(NOW - 3600_000).toISOString(),
200
+ });
201
+ const picked = pickResumeParent([recent_pending, older_in_progress], "agent-A");
202
+ expect(picked?.id).toBe("oip");
203
+ });
204
+
205
+ test("among same-status siblings, most recent wins", () => {
206
+ const older = sibling({ id: "o", updatedAt: new Date(NOW - 3600_000).toISOString() });
207
+ const newer = sibling({ id: "n", updatedAt: new Date(NOW - 60_000).toISOString() });
208
+ const picked = pickResumeParent([older, newer], "agent-A");
209
+ expect(picked?.id).toBe("n");
210
+ });
211
+
212
+ test("ordering: in_progress > pending > offered > paused", () => {
213
+ const all = [
214
+ sibling({ id: "p", status: "paused" }),
215
+ sibling({ id: "o", status: "offered" }),
216
+ sibling({ id: "pe", status: "pending" }),
217
+ sibling({ id: "ip", status: "in_progress" }),
218
+ ];
219
+ expect(pickResumeParent(all, "agent-A")?.id).toBe("ip");
220
+ expect(pickResumeParent(all.slice(0, 3), "agent-A")?.id).toBe("pe");
221
+ expect(pickResumeParent(all.slice(0, 2), "agent-A")?.id).toBe("o");
222
+ expect(pickResumeParent(all.slice(0, 1), "agent-A")?.id).toBe("p");
223
+ });
224
+
225
+ test("ignores siblings with null agentId", () => {
226
+ const out = pickResumeParent(
227
+ [sibling({ agentId: null }), sibling({ id: "ok", agentId: "agent-A" })],
228
+ "agent-A",
229
+ );
230
+ expect(out?.id).toBe("ok");
231
+ });
232
+ });
@@ -159,6 +159,7 @@ describe("Tool Annotations & Classification", () => {
159
159
  "slack-reply",
160
160
  "slack-read",
161
161
  "slack-post",
162
+ "slack-start-thread",
162
163
  "slack-upload-file",
163
164
  "slack-download-file",
164
165
  "slack-list-channels",
@@ -9,14 +9,20 @@ export const registerSlackPostTool = (server: McpServer) => {
9
9
  createToolRegistrar(server)(
10
10
  "slack-post",
11
11
  {
12
- title: "Post new message to Slack channel",
12
+ title: "Post message to Slack channel",
13
13
  description:
14
- "Post a new message to a Slack channel. This creates a new message (not a thread reply). Requires lead privileges.",
14
+ "Post a message to a Slack channel. By default creates a new top-level message; pass `threadTs` to post as a threaded reply under an existing message (obtain the ts from `slack-start-thread`). Requires lead privileges.",
15
15
  annotations: { openWorldHint: true },
16
16
 
17
17
  inputSchema: z.object({
18
18
  channelId: z.string().min(1).describe("The Slack channel ID to post to."),
19
19
  message: z.string().min(1).max(4000).describe("The message content to post."),
20
+ threadTs: z
21
+ .string()
22
+ .optional()
23
+ .describe(
24
+ "Optional parent message ts to thread under. Obtain via `slack-start-thread`. When omitted, posts as a new top-level message.",
25
+ ),
20
26
  }),
21
27
  outputSchema: z.object({
22
28
  success: z.boolean(),
@@ -24,7 +30,7 @@ export const registerSlackPostTool = (server: McpServer) => {
24
30
  messageTs: z.string().optional(),
25
31
  }),
26
32
  },
27
- async ({ channelId, message }, requestInfo, _meta) => {
33
+ async ({ channelId, message, threadTs }, requestInfo, _meta) => {
28
34
  if (!requestInfo.agentId) {
29
35
  return {
30
36
  content: [{ type: "text", text: "Agent ID not found." }],
@@ -67,6 +73,7 @@ export const registerSlackPostTool = (server: McpServer) => {
67
73
  text: slackMessage, // Fallback for notifications
68
74
  username: agent.name,
69
75
  icon_emoji: ":crown:",
76
+ ...(threadTs ? { thread_ts: threadTs } : {}),
70
77
  blocks: [
71
78
  {
72
79
  type: "section",
@@ -0,0 +1,123 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getAgentById } from "@/be/db";
4
+ import { getSlackApp } from "@/slack/app";
5
+ import { markdownToSlack } from "@/slack/responses";
6
+ import { createToolRegistrar } from "@/tools/utils";
7
+
8
+ export const registerSlackStartThreadTool = (server: McpServer) => {
9
+ createToolRegistrar(server)(
10
+ "slack-start-thread",
11
+ {
12
+ title: "Start a new Slack thread",
13
+ description:
14
+ "Post a new top-level message to a Slack channel and return its ts so the caller can thread replies under it. Pass the returned `ts` as `threadTs` on subsequent `slack-post` calls to keep replies in the same thread. Requires lead privileges.",
15
+ annotations: { openWorldHint: true },
16
+
17
+ inputSchema: z.object({
18
+ channelId: z.string().min(1).describe("The Slack channel ID to post to."),
19
+ message: z.string().min(1).max(4000).describe("The message content to post."),
20
+ }),
21
+ outputSchema: z.object({
22
+ success: z.boolean(),
23
+ message: z.string(),
24
+ channelId: z.string().optional(),
25
+ ts: z.string().optional(),
26
+ }),
27
+ },
28
+ async ({ channelId, message }, requestInfo, _meta) => {
29
+ if (!requestInfo.agentId) {
30
+ return {
31
+ content: [{ type: "text", text: "Agent ID not found." }],
32
+ structuredContent: { success: false, message: "Agent ID not found." },
33
+ };
34
+ }
35
+
36
+ const agent = getAgentById(requestInfo.agentId);
37
+ if (!agent) {
38
+ return {
39
+ content: [{ type: "text", text: "Agent not found." }],
40
+ structuredContent: { success: false, message: "Agent not found." },
41
+ };
42
+ }
43
+
44
+ if (!agent.isLead) {
45
+ return {
46
+ content: [{ type: "text", text: "Posting to Slack channels requires lead privileges." }],
47
+ structuredContent: {
48
+ success: false,
49
+ message: "Posting to Slack channels requires lead privileges.",
50
+ },
51
+ };
52
+ }
53
+
54
+ const app = getSlackApp();
55
+ if (!app) {
56
+ return {
57
+ content: [{ type: "text", text: "Slack not configured." }],
58
+ structuredContent: { success: false, message: "Slack not configured." },
59
+ };
60
+ }
61
+
62
+ try {
63
+ const slackMessage = markdownToSlack(message);
64
+
65
+ const result = await app.client.chat.postMessage({
66
+ channel: channelId,
67
+ text: slackMessage, // Fallback for notifications
68
+ username: agent.name,
69
+ icon_emoji: ":crown:",
70
+ blocks: [
71
+ {
72
+ type: "section",
73
+ text: {
74
+ type: "mrkdwn",
75
+ text: slackMessage,
76
+ },
77
+ },
78
+ ],
79
+ });
80
+
81
+ const ts = result.ts;
82
+ const resolvedChannelId = result.channel ?? channelId;
83
+
84
+ if (!ts) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: "Message posted but Slack did not return a ts — cannot thread replies.",
90
+ },
91
+ ],
92
+ structuredContent: {
93
+ success: false,
94
+ message: "Message posted but Slack did not return a ts — cannot thread replies.",
95
+ channelId: resolvedChannelId,
96
+ },
97
+ };
98
+ }
99
+
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: `Thread started. channelId=${resolvedChannelId}, ts=${ts}. Pass ts as threadTs on slack-post to reply in-thread.`,
105
+ },
106
+ ],
107
+ structuredContent: {
108
+ success: true,
109
+ message: "Thread started successfully.",
110
+ channelId: resolvedChannelId,
111
+ ts,
112
+ },
113
+ };
114
+ } catch (error) {
115
+ const errorMsg = error instanceof Error ? error.message : String(error);
116
+ return {
117
+ content: [{ type: "text", text: `Failed to start thread: ${errorMsg}` }],
118
+ structuredContent: { success: false, message: `Failed to start thread: ${errorMsg}` },
119
+ };
120
+ }
121
+ },
122
+ );
123
+ };
@@ -80,13 +80,14 @@ export const DEFERRED_TOOLS = new Set([
80
80
  "context-history",
81
81
  "context-diff",
82
82
 
83
- // Slack (6)
83
+ // Slack (7)
84
84
  "slack-reply",
85
85
  "slack-read",
86
86
  "slack-upload-file",
87
87
  "slack-download-file",
88
88
  "slack-list-channels",
89
89
  "slack-post",
90
+ "slack-start-thread",
90
91
 
91
92
  // Channel management (2)
92
93
  "create-channel",
@@ -226,9 +226,12 @@ export const registerUpdateProfileTool = (server: McpServer) => {
226
226
  },
227
227
  );
228
228
 
229
- // Write updated files to workspace only when updating self
229
+ // Write updated files to workspace only when updating self AND the caller
230
+ // matches the real running agent (process.env.AGENT_ID). This guards against
231
+ // unit tests (with fake WORKER_IDs) accidentally overwriting the container's
232
+ // SOUL.md/IDENTITY.md when the test suite runs inside a real agent container.
230
233
  // (remote agent files live on their own container)
231
- if (isUpdatingSelf) {
234
+ if (isUpdatingSelf && requestInfo.agentId === process.env.AGENT_ID) {
232
235
  if (soulMd !== undefined) {
233
236
  try {
234
237
  await Bun.write("/workspace/SOUL.md", soulMd);
package/src/types.ts CHANGED
@@ -141,6 +141,11 @@ export const AgentTaskSchema = z.object({
141
141
  workflowRunId: z.string().uuid().nullable().optional(),
142
142
  workflowRunStepId: z.string().uuid().nullable().optional(),
143
143
 
144
+ // Cross-ingress context key — uniform identifier for the "context entity"
145
+ // (Slack thread, GitHub issue, Linear issue, schedule, workflow run, ...).
146
+ // See src/tasks/context-key.ts. Nullable: legacy rows stay NULL.
147
+ contextKey: z.string().optional(),
148
+
144
149
  // Structured output schema (optional — JSON Schema that task output must conform to)
145
150
  outputSchema: z.record(z.string(), z.unknown()).optional(),
146
151
 
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { workflowContextKey } from "../../tasks/context-key";
3
+ import { withSiblingAwareness } from "../../tasks/sibling-awareness";
2
4
  import type { ExecutorMeta } from "../../types";
3
5
  import type { ExecutorResult } from "./base";
4
6
  import { BaseExecutor } from "./base";
@@ -77,20 +79,25 @@ export class AgentTaskExecutor extends BaseExecutor<
77
79
  }
78
80
 
79
81
  // 3. Create the task (config is already deep-interpolated by the engine)
80
- const task = db.createTaskExtended(config.template, {
81
- agentId: config.agentId ?? null,
82
- source: "workflow",
83
- tags: config.tags,
84
- priority: config.priority,
85
- offeredTo: config.offerMode ? config.agentId : undefined,
86
- workflowRunId: meta.runId,
87
- workflowRunStepId: meta.stepId,
88
- dir: effectiveDir,
89
- vcsRepo: effectiveVcsRepo,
90
- model: config.model,
91
- parentTaskId: config.parentTaskId,
92
- outputSchema: config.outputSchema,
93
- });
82
+ const { description: taskDescription, options: taskOptions } = withSiblingAwareness(
83
+ config.template,
84
+ {
85
+ agentId: config.agentId ?? null,
86
+ source: "workflow",
87
+ tags: config.tags,
88
+ priority: config.priority,
89
+ offeredTo: config.offerMode ? config.agentId : undefined,
90
+ workflowRunId: meta.runId,
91
+ workflowRunStepId: meta.stepId,
92
+ dir: effectiveDir,
93
+ vcsRepo: effectiveVcsRepo,
94
+ model: config.model,
95
+ parentTaskId: config.parentTaskId,
96
+ outputSchema: config.outputSchema,
97
+ contextKey: workflowContextKey({ workflowRunId: meta.runId }),
98
+ },
99
+ );
100
+ const task = db.createTaskExtended(taskDescription, taskOptions);
94
101
 
95
102
  // 4. Return async result — engine will pause the workflow
96
103
  return {