@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.
- package/README.md +3 -3
- package/openapi.json +62 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +87 -6
- package/src/be/db.ts +34 -2
- package/src/be/migrations/042_task_context_key.sql +13 -0
- package/src/commands/runner.ts +1 -0
- package/src/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/schedules.ts +4 -2
- package/src/http/tasks.ts +4 -2
- package/src/linear/sync.ts +22 -10
- package/src/providers/claude-adapter.ts +51 -29
- package/src/scheduler/scheduler.ts +9 -10
- package/src/server.ts +2 -0
- package/src/slack/actions.ts +10 -9
- package/src/slack/assistant.ts +8 -4
- package/src/slack/handlers.ts +8 -3
- package/src/slack/thread-buffer.ts +61 -72
- package/src/tasks/additive-buffer.ts +152 -0
- package/src/tasks/additive-ingress.ts +125 -0
- package/src/tasks/context-key.ts +245 -0
- package/src/tasks/sibling-awareness.ts +144 -0
- package/src/tasks/sibling-block.ts +164 -0
- package/src/tests/additive-buffer.test.ts +186 -0
- package/src/tests/additive-ingress.test.ts +111 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/types.ts +5 -0
- 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
|
+
});
|
package/src/tools/slack-post.ts
CHANGED
|
@@ -9,14 +9,20 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
9
9
|
createToolRegistrar(server)(
|
|
10
10
|
"slack-post",
|
|
11
11
|
{
|
|
12
|
-
title: "Post
|
|
12
|
+
title: "Post message to Slack channel",
|
|
13
13
|
description:
|
|
14
|
-
"Post a
|
|
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
|
+
};
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -80,13 +80,14 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
80
80
|
"context-history",
|
|
81
81
|
"context-diff",
|
|
82
82
|
|
|
83
|
-
// Slack (
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 {
|