@desplega.ai/agent-swarm 1.115.0 → 1.117.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 +2 -2
- package/dist/{actions-17ya5wde.js → actions-7bnvhcgs.js} +10 -8
- package/dist/{app-4f718yk0.js → app-976fykvr.js} +3 -3
- package/dist/{assistant-ah7csnjz.js → assistant-5drs03t0.js} +13 -11
- package/dist/{boot-reembed-4jpxhb25.js → boot-reembed-0zhftca3.js} +4 -4
- package/dist/{boot-reembed-qata34xa.js → boot-reembed-wce58xfv.js} +3 -3
- package/dist/{boot-scrub-logs-tv48nn3z.js → boot-scrub-logs-vkd2vvxp.js} +2 -2
- package/dist/{cli-zsrdty74.js → cli-09j7m5v3.js} +4 -4
- package/dist/{cli-beea4a3y.js → cli-1987vgjc.js} +3 -3
- package/dist/{cli-k3y4z3h5.js → cli-5ekf4gcj.js} +2 -76
- package/dist/{cli-cnwssmsr.js → cli-6058t3w0.js} +1 -1
- package/dist/{cli-tffvrh6j.js → cli-9kk0xa5w.js} +1 -1
- package/dist/{cli-q1rx7g0d.js → cli-e0xv4nd4.js} +11 -9
- package/dist/{cli-12vb0xg6.js → cli-f5cx2j92.js} +2 -2
- package/dist/{cli-3samkyap.js → cli-fep22bhf.js} +2 -2
- package/dist/{cli-t0vqevnm.js → cli-hmw1vk30.js} +1 -1
- package/dist/{cli-pet3aev6.js → cli-jkp1gndy.js} +5 -5
- package/dist/{cli-52fg7s7x.js → cli-kfjs4fjw.js} +1 -1
- package/dist/{cli-65ekje01.js → cli-m4p6c600.js} +10 -8
- package/dist/{cli-cchksf5q.js → cli-ndsyqqqk.js} +2 -2
- package/dist/{cli-tece6zgm.js → cli-q0yn57de.js} +1 -1
- package/dist/{cli-hrhye2rm.js → cli-r06ed00c.js} +70 -24
- package/dist/{cli-7xje5amz.js → cli-rymspw51.js} +1 -1
- package/dist/{cli-15y7qhzp.js → cli-s5q026mr.js} +295 -4
- package/dist/{cli-shvaxw27.js → cli-t6n84nqv.js} +8 -6
- package/dist/{cli-r9f6ngrf.js → cli-vwhg6sbn.js} +2 -2
- package/dist/{cli-6aq8xhvm.js → cli-w5axeg22.js} +1 -1
- package/dist/{cli-b23axgz4.js → cli-xcp5dmts.js} +2 -2
- package/dist/{cli-dcca6j8m.js → cli-yarjrgd5.js} +1 -1
- package/dist/{cli-cmw9rzy5.js → cli-z824vphk.js} +2 -2
- package/dist/cli.js +10 -10
- package/dist/{commands-hch4e2k1.js → commands-b3p0fjzd.js} +2 -2
- package/dist/{db-p3h2j06x.js → db-6xw82vz3.js} +2 -2
- package/dist/{handlers-9er4zx9q.js → handlers-kwrk085v.js} +13 -11
- package/dist/{hook-4axs756z.js → hook-e4wdztg5.js} +1 -1
- package/dist/{http-809fvtz2.js → http-x7svk8q5.js} +41 -37
- package/dist/{index-rm85cfdx.js → index-35tw5g2v.js} +6 -6
- package/dist/{index-47em3d2n.js → index-aadhkkde.js} +10 -10
- package/dist/{index-9fqaktjr.js → index-qb6nfqmy.js} +8 -8
- package/dist/{index-9ht8gr56.js → index-zj38e3q2.js} +9 -9
- package/dist/{keepalive-3cps6e3e.js → keepalive-enehyqc8.js} +4 -4
- package/dist/{lead-fkk95wgq.js → lead-nmzd0ykq.js} +18 -18
- package/dist/{maintenance-fweqh0yj.js → maintenance-staxb3xt.js} +4 -4
- package/dist/{oauth-refresh-sweep-sycqtbph.js → oauth-refresh-sweep-fqczcwxn.js} +3 -3
- package/dist/{onboard-cy0rkgvx.js → onboard-shmqj6ep.js} +2 -2
- package/dist/{otel-impl-wbns8hf7.js → otel-impl-726bmdyc.js} +1 -1
- package/dist/{pricing-refresh-0hvazmkf.js → pricing-refresh-rzwka8rf.js} +4 -4
- package/dist/{rbac-roles-kfesjy9y.js → rbac-roles-chkhakmf.js} +3 -3
- package/dist/{rbac-roles-mwcwhbxc.js → rbac-roles-j2k0h8sx.js} +2 -2
- package/dist/{seed-pricing-h2kdq4cq.js → seed-pricing-vta6a1q3.js} +3 -3
- package/dist/{setup-8506q97w.js → setup-yv9ctywz.js} +2 -2
- package/dist/{worker-4r5c6zyb.js → worker-7w6571j0.js} +18 -18
- package/openapi.json +1 -1
- package/package.json +2 -2
- package/src/be/db.ts +89 -3
- package/src/server-user.ts +1 -0
- package/src/tasks/slack-routing.ts +147 -0
- package/src/tests/approval-requests.test.ts +36 -0
- package/src/tests/http-api-integration.test.ts +38 -0
- package/src/tests/pages-complete-fetch.test.ts +54 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +355 -0
- package/src/tests/slack-metadata-inheritance.test.ts +279 -1
- package/src/tests/slack-routing-guard.test.ts +164 -0
- package/src/tools/send-task.ts +145 -82
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { linearContextKey, slackContextKey } from "../tasks/context-key";
|
|
3
|
+
import { checkSlackRoutingCoherence, slackChannelFromContextKey } from "../tasks/slack-routing";
|
|
4
|
+
|
|
5
|
+
describe("slackChannelFromContextKey", () => {
|
|
6
|
+
test("extracts channel/thread from a slack-family key", () => {
|
|
7
|
+
const key = slackContextKey({ channelId: "C_ABC", threadTs: "111.222" });
|
|
8
|
+
expect(slackChannelFromContextKey(key)).toEqual({ channelId: "C_ABC", threadTs: "111.222" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("returns null for a non-slack family key", () => {
|
|
12
|
+
const key = linearContextKey({ issueIdentifier: "DES-42" });
|
|
13
|
+
expect(slackChannelFromContextKey(key)).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("returns null for a malformed key instead of throwing", () => {
|
|
17
|
+
expect(slackChannelFromContextKey("not-a-context-key")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns null for undefined/null input", () => {
|
|
21
|
+
expect(slackChannelFromContextKey(undefined)).toBeNull();
|
|
22
|
+
expect(slackChannelFromContextKey(null)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("checkSlackRoutingCoherence", () => {
|
|
27
|
+
test("no explicit unit → ok (nothing to check)", () => {
|
|
28
|
+
const result = checkSlackRoutingCoherence({ explicit: {} });
|
|
29
|
+
expect(result.verdict).toBe("ok");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("channel without threadTs → partial-unit", () => {
|
|
33
|
+
const result = checkSlackRoutingCoherence({ explicit: { channelId: "C_A" } });
|
|
34
|
+
expect(result.verdict).toBe("partial-unit");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("threadTs without channel → partial-unit", () => {
|
|
38
|
+
const result = checkSlackRoutingCoherence({ explicit: { threadTs: "111.222" } });
|
|
39
|
+
expect(result.verdict).toBe("partial-unit");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("userId alone (no channel/thread) → ok — attribution only", () => {
|
|
43
|
+
const result = checkSlackRoutingCoherence({ explicit: { userId: "U_SOMEONE" } });
|
|
44
|
+
expect(result.verdict).toBe("ok");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("no parent, no contextKey → complete explicit unit accepted (new Slack root)", () => {
|
|
48
|
+
const result = checkSlackRoutingCoherence({
|
|
49
|
+
explicit: { channelId: "C_NEW", threadTs: "111.222" },
|
|
50
|
+
});
|
|
51
|
+
expect(result.verdict).toBe("ok");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("explicit unit matches parent's slackChannelId and slackThreadTs → ok", () => {
|
|
55
|
+
const result = checkSlackRoutingCoherence({
|
|
56
|
+
explicit: { channelId: "C_PARENT", threadTs: "111.222" },
|
|
57
|
+
parent: { slackChannelId: "C_PARENT", slackThreadTs: "111.222", contextKey: null },
|
|
58
|
+
});
|
|
59
|
+
expect(result.verdict).toBe("ok");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("explicit channelId matches parent's but threadTs diverges → mismatch (source: parent)", () => {
|
|
63
|
+
const result = checkSlackRoutingCoherence({
|
|
64
|
+
explicit: { channelId: "C_PARENT", threadTs: "999.999" },
|
|
65
|
+
parent: { slackChannelId: "C_PARENT", slackThreadTs: "111.222", contextKey: null },
|
|
66
|
+
});
|
|
67
|
+
expect(result.verdict).toBe("mismatch");
|
|
68
|
+
if (result.verdict === "mismatch") {
|
|
69
|
+
expect(result.field).toBe("slackThreadTs");
|
|
70
|
+
expect(result.expectedSource).toBe("parent");
|
|
71
|
+
expect(result.expected).toBe("111.222");
|
|
72
|
+
expect(result.got).toBe("999.999");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("explicit channelId mismatches parent's slackChannelId → mismatch (source: parent)", () => {
|
|
77
|
+
const result = checkSlackRoutingCoherence({
|
|
78
|
+
explicit: { channelId: "C_WRONG", threadTs: "111.222" },
|
|
79
|
+
parent: { slackChannelId: "C_PARENT", slackThreadTs: "999.999", contextKey: null },
|
|
80
|
+
});
|
|
81
|
+
expect(result.verdict).toBe("mismatch");
|
|
82
|
+
if (result.verdict === "mismatch") {
|
|
83
|
+
expect(result.field).toBe("slackChannelId");
|
|
84
|
+
expect(result.expectedSource).toBe("parent");
|
|
85
|
+
expect(result.expected).toBe("C_PARENT");
|
|
86
|
+
expect(result.got).toBe("C_WRONG");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("explicit channelId mismatches the inherited slack contextKey → mismatch (source: contextKey)", () => {
|
|
91
|
+
const contextKey = slackContextKey({ channelId: "C_GEROLD", threadTs: "111.222" });
|
|
92
|
+
const result = checkSlackRoutingCoherence({
|
|
93
|
+
explicit: { channelId: "C_DANIEL", threadTs: "111.222" },
|
|
94
|
+
inheritedContextKey: contextKey,
|
|
95
|
+
});
|
|
96
|
+
expect(result.verdict).toBe("mismatch");
|
|
97
|
+
if (result.verdict === "mismatch") {
|
|
98
|
+
expect(result.field).toBe("slackChannelId");
|
|
99
|
+
expect(result.expectedSource).toBe("contextKey");
|
|
100
|
+
expect(result.expected).toBe("C_GEROLD");
|
|
101
|
+
expect(result.got).toBe("C_DANIEL");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("explicit threadTs mismatches the inherited slack contextKey's thread → mismatch", () => {
|
|
106
|
+
const contextKey = slackContextKey({ channelId: "C_GEROLD", threadTs: "111.222" });
|
|
107
|
+
const result = checkSlackRoutingCoherence({
|
|
108
|
+
explicit: { channelId: "C_GEROLD", threadTs: "333.444" },
|
|
109
|
+
inheritedContextKey: contextKey,
|
|
110
|
+
});
|
|
111
|
+
expect(result.verdict).toBe("mismatch");
|
|
112
|
+
if (result.verdict === "mismatch") {
|
|
113
|
+
expect(result.field).toBe("slackThreadTs");
|
|
114
|
+
expect(result.expectedSource).toBe("contextKey");
|
|
115
|
+
expect(result.expected).toBe("111.222");
|
|
116
|
+
expect(result.got).toBe("333.444");
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("incident replay: explicit unit disagrees with BOTH parent and contextKey → rejects on parent first", () => {
|
|
121
|
+
const contextKey = slackContextKey({ channelId: "D0ATCHCQR4M", threadTs: "1783596696.921879" });
|
|
122
|
+
const result = checkSlackRoutingCoherence({
|
|
123
|
+
explicit: { channelId: "D0ASZJS6HUN", threadTs: "1783596696.921879" },
|
|
124
|
+
parent: { slackChannelId: "D0ATCHCQR4M", slackThreadTs: "1783596696.921879", contextKey },
|
|
125
|
+
inheritedContextKey: contextKey,
|
|
126
|
+
});
|
|
127
|
+
expect(result.verdict).toBe("mismatch");
|
|
128
|
+
if (result.verdict === "mismatch") {
|
|
129
|
+
expect(result.expectedSource).toBe("parent");
|
|
130
|
+
expect(result.expected).toBe("D0ATCHCQR4M");
|
|
131
|
+
expect(result.got).toBe("D0ASZJS6HUN");
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("parent has non-slack contextKey → only parent-field comparison applies, unit accepted", () => {
|
|
136
|
+
const result = checkSlackRoutingCoherence({
|
|
137
|
+
explicit: { channelId: "C_NEW", threadTs: "111.222" },
|
|
138
|
+
parent: {
|
|
139
|
+
slackChannelId: null,
|
|
140
|
+
slackThreadTs: null,
|
|
141
|
+
contextKey: linearContextKey({ issueIdentifier: "DES-1" }),
|
|
142
|
+
},
|
|
143
|
+
inheritedContextKey: linearContextKey({ issueIdentifier: "DES-1" }),
|
|
144
|
+
});
|
|
145
|
+
expect(result.verdict).toBe("ok");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("malformed inherited contextKey degrades to parent-only comparison, no throw", () => {
|
|
149
|
+
const result = checkSlackRoutingCoherence({
|
|
150
|
+
explicit: { channelId: "C_PARENT", threadTs: "111.222" },
|
|
151
|
+
parent: { slackChannelId: "C_PARENT", slackThreadTs: "111.222", contextKey: "garbage" },
|
|
152
|
+
inheritedContextKey: "garbage",
|
|
153
|
+
});
|
|
154
|
+
expect(result.verdict).toBe("ok");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("explicit unit identical to parent's (verbatim-copy pattern) → ok, no behavior change", () => {
|
|
158
|
+
const result = checkSlackRoutingCoherence({
|
|
159
|
+
explicit: { channelId: "C_SAME", threadTs: "111.222", userId: "U_SAME" },
|
|
160
|
+
parent: { slackChannelId: "C_SAME", slackThreadTs: "111.222", contextKey: null },
|
|
161
|
+
});
|
|
162
|
+
expect(result.verdict).toBe("ok");
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/tools/send-task.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hasCapacity,
|
|
14
14
|
} from "@/be/db";
|
|
15
15
|
import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
|
|
16
|
+
import { checkSlackRoutingCoherence } from "@/tasks/slack-routing";
|
|
16
17
|
import { findDuplicateTask } from "@/tools/task-dedup";
|
|
17
18
|
import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
|
|
18
19
|
import { createToolRegistrar } from "@/tools/utils";
|
|
@@ -25,91 +26,113 @@ import {
|
|
|
25
26
|
splitLegacyModelAlias,
|
|
26
27
|
} from "@/types";
|
|
27
28
|
|
|
28
|
-
export const sendTaskInputSchema = z
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
29
|
+
export const sendTaskInputSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
agentId: z
|
|
32
|
+
.uuid()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("The agent to assign/offer task to. Omit to create unassigned task for pool."),
|
|
35
|
+
task: z.string().min(1).describe("The task description to send."),
|
|
36
|
+
offerMode: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.default(false)
|
|
39
|
+
.describe("If true, offer the task instead of direct assign (agent must accept/reject)."),
|
|
40
|
+
taskType: z
|
|
41
|
+
.string()
|
|
42
|
+
.max(50)
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Task type (e.g., 'bug', 'feature', 'review')."),
|
|
45
|
+
tags: z
|
|
46
|
+
.array(z.string())
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Tags for filtering (e.g., ['urgent', 'frontend'])."),
|
|
49
|
+
requiredCapabilities: z
|
|
50
|
+
.array(z.string())
|
|
51
|
+
.optional()
|
|
52
|
+
.describe(
|
|
53
|
+
"Capabilities a claiming agent must have (declared via join-swarm/update-profile) to be pool-eligible for this task. Written into the created task's routingAffinity (role is left unset — only enforced when the pool auto-claim/claim-tool paths check it). Most useful when omitting agentId (unassigned pool task); a no-op for a task with an explicit agentId, which bypasses the pool gate entirely.",
|
|
54
|
+
),
|
|
55
|
+
priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100 (default: 50)."),
|
|
56
|
+
dependsOn: z.array(z.uuid()).optional().describe("Task IDs this task depends on."),
|
|
57
|
+
parentTaskId: z
|
|
58
|
+
.uuid()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe(
|
|
61
|
+
"Parent task ID for session continuity. Child task will resume the parent's Claude session. Auto-routes to the same worker unless agentId is explicitly provided.",
|
|
62
|
+
),
|
|
63
|
+
dir: z
|
|
64
|
+
.string()
|
|
65
|
+
.min(1)
|
|
66
|
+
.startsWith("/")
|
|
67
|
+
.optional()
|
|
68
|
+
.describe(
|
|
69
|
+
"Working directory (absolute path) for the agent to start in. If the directory doesn't exist, falls back to the default working directory.",
|
|
70
|
+
),
|
|
71
|
+
vcsRepo: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe(
|
|
75
|
+
"VCS repo identifier (e.g., 'desplega-ai/agent-swarm' for GitHub or 'group/project' for GitLab). Links the task to a registered repo for workspace context.",
|
|
76
|
+
),
|
|
77
|
+
model: z
|
|
78
|
+
.string()
|
|
79
|
+
.trim()
|
|
80
|
+
.min(1)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(
|
|
83
|
+
"Concrete model override for this task, interpreted by the assignee's harness/provider. This does not switch providers. Prefer modelTier for portable intent.",
|
|
84
|
+
),
|
|
85
|
+
modelTier: ModelTierSchema.optional().describe(
|
|
86
|
+
"Portable model tier for this task: 'smol', 'regular', 'smart', or 'ultra'. Resolved at claim/run time using the assignee's harness/provider. Legacy model shortnames map as haiku→smol, sonnet→regular, opus→smart, fable→ultra.",
|
|
48
87
|
),
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
parentTaskId: z
|
|
52
|
-
.uuid()
|
|
53
|
-
.optional()
|
|
54
|
-
.describe(
|
|
55
|
-
"Parent task ID for session continuity. Child task will resume the parent's Claude session. Auto-routes to the same worker unless agentId is explicitly provided.",
|
|
88
|
+
effort: ReasoningEffortSchema.optional().describe(
|
|
89
|
+
"Reasoning effort for this task: 'off', 'low', 'medium', 'high', 'xhigh', or 'max'. If omitted, the assignee's REASONING_EFFORT_OVERRIDE/default applies.",
|
|
56
90
|
),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
allowDuplicate: z
|
|
92
|
+
.boolean()
|
|
93
|
+
.default(false)
|
|
94
|
+
.describe(
|
|
95
|
+
"If true, skip duplicate detection and create the task even if a similar one exists.",
|
|
96
|
+
),
|
|
97
|
+
slackChannelId: z
|
|
98
|
+
.string()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe(
|
|
101
|
+
"Slack channel ID to post progress updates to. Use this to propagate Slack context when delegating from a Slack thread.",
|
|
102
|
+
),
|
|
103
|
+
slackThreadTs: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Slack thread timestamp. Required with slackChannelId for thread-level updates."),
|
|
107
|
+
slackUserId: z.string().optional().describe("Slack user ID of the original requester."),
|
|
108
|
+
overrideSlackContext: z
|
|
109
|
+
.boolean()
|
|
110
|
+
.default(false)
|
|
111
|
+
.describe(
|
|
112
|
+
"Explicitly route this task's Slack updates to a different channel/thread than its parent/contextKey. Requires slackChannelId AND slackThreadTs. Use only for deliberate cross-channel dispatch (e.g. escalation to another human's DM); logged for audit. Without this flag, a slackChannelId/slackThreadTs that disagrees with the parent task or inherited contextKey is rejected — omit the three Slack fields to inherit them from the parent as a unit instead.",
|
|
113
|
+
),
|
|
114
|
+
requestedByUserId: z
|
|
115
|
+
.string()
|
|
116
|
+
.uuid()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe(
|
|
119
|
+
"ID of the human user who originally requested this task chain. When omitted, inherited from the caller's current task so the attribution flows through multi-hop delegation automatically.",
|
|
120
|
+
),
|
|
121
|
+
followUpConfig: FollowUpConfigSchema.optional().describe(
|
|
122
|
+
"Control the lead follow-up created when this task finishes. When to use `followUpConfig`: set `disabled: true` when you'll wait for this task to complete inline and no follow-up is needed; set `onCompleted` / `onFailed` with specific instructions when you need to follow up effectively on a particular outcome of a long-running flow; for normal one-shot tasks, leave it unset because defaults are fine. It is most valuable for long-running / complex flows.",
|
|
64
123
|
),
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"Concrete model override for this task, interpreted by the assignee's harness/provider. This does not switch providers. Prefer modelTier for portable intent.",
|
|
78
|
-
),
|
|
79
|
-
modelTier: ModelTierSchema.optional().describe(
|
|
80
|
-
"Portable model tier for this task: 'smol', 'regular', 'smart', or 'ultra'. Resolved at claim/run time using the assignee's harness/provider. Legacy model shortnames map as haiku→smol, sonnet→regular, opus→smart, fable→ultra.",
|
|
81
|
-
),
|
|
82
|
-
effort: ReasoningEffortSchema.optional().describe(
|
|
83
|
-
"Reasoning effort for this task: 'off', 'low', 'medium', 'high', 'xhigh', or 'max'. If omitted, the assignee's REASONING_EFFORT_OVERRIDE/default applies.",
|
|
84
|
-
),
|
|
85
|
-
allowDuplicate: z
|
|
86
|
-
.boolean()
|
|
87
|
-
.default(false)
|
|
88
|
-
.describe(
|
|
89
|
-
"If true, skip duplicate detection and create the task even if a similar one exists.",
|
|
90
|
-
),
|
|
91
|
-
slackChannelId: z
|
|
92
|
-
.string()
|
|
93
|
-
.optional()
|
|
94
|
-
.describe(
|
|
95
|
-
"Slack channel ID to post progress updates to. Use this to propagate Slack context when delegating from a Slack thread.",
|
|
96
|
-
),
|
|
97
|
-
slackThreadTs: z
|
|
98
|
-
.string()
|
|
99
|
-
.optional()
|
|
100
|
-
.describe("Slack thread timestamp. Required with slackChannelId for thread-level updates."),
|
|
101
|
-
slackUserId: z.string().optional().describe("Slack user ID of the original requester."),
|
|
102
|
-
requestedByUserId: z
|
|
103
|
-
.string()
|
|
104
|
-
.uuid()
|
|
105
|
-
.optional()
|
|
106
|
-
.describe(
|
|
107
|
-
"ID of the human user who originally requested this task chain. When omitted, inherited from the caller's current task so the attribution flows through multi-hop delegation automatically.",
|
|
108
|
-
),
|
|
109
|
-
followUpConfig: FollowUpConfigSchema.optional().describe(
|
|
110
|
-
"Control the lead follow-up created when this task finishes. When to use `followUpConfig`: set `disabled: true` when you'll wait for this task to complete inline and no follow-up is needed; set `onCompleted` / `onFailed` with specific instructions when you need to follow up effectively on a particular outcome of a long-running flow; for normal one-shot tasks, leave it unset because defaults are fine. It is most valuable for long-running / complex flows.",
|
|
111
|
-
),
|
|
112
|
-
});
|
|
124
|
+
})
|
|
125
|
+
.superRefine((data, ctx) => {
|
|
126
|
+
const hasChannel = !!data.slackChannelId;
|
|
127
|
+
const hasThread = !!data.slackThreadTs;
|
|
128
|
+
if (hasChannel !== hasThread) {
|
|
129
|
+
ctx.addIssue({
|
|
130
|
+
code: z.ZodIssueCode.custom,
|
|
131
|
+
message: "slackChannelId and slackThreadTs must both be set, or both omitted.",
|
|
132
|
+
path: [hasChannel ? "slackThreadTs" : "slackChannelId"],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
113
136
|
|
|
114
137
|
export const sendTaskOutputSchema = z.object({
|
|
115
138
|
yourAgentId: z.string().uuid().optional(),
|
|
@@ -175,6 +198,7 @@ export async function sendTaskHandler(
|
|
|
175
198
|
slackChannelId,
|
|
176
199
|
slackThreadTs,
|
|
177
200
|
slackUserId,
|
|
201
|
+
overrideSlackContext,
|
|
178
202
|
requestedByUserId: inputRequestedByUserId,
|
|
179
203
|
followUpConfig,
|
|
180
204
|
}: SendTaskArgs,
|
|
@@ -226,6 +250,42 @@ export async function sendTaskHandler(
|
|
|
226
250
|
const effectiveParentTaskId = parentTaskId ?? sourceTaskId;
|
|
227
251
|
const effectiveParentTask = effectiveParentTaskId ? getTaskById(effectiveParentTaskId) : null;
|
|
228
252
|
|
|
253
|
+
// Slack-routing coherence guard: reject a hand-typed slackChannelId/slackThreadTs
|
|
254
|
+
// that disagrees with the parent task or the contextKey this child will inherit.
|
|
255
|
+
// A mismatch here silently misroutes a worker's completion summary into the
|
|
256
|
+
// wrong human's Slack DM (see swarm memory
|
|
257
|
+
// dispatch-slack-channel-must-match-parent-context-2026-07-10). Omitting the
|
|
258
|
+
// three Slack fields lets inheritance do the right thing; overrideSlackContext
|
|
259
|
+
// opts into a deliberate cross-channel dispatch.
|
|
260
|
+
if (!overrideSlackContext) {
|
|
261
|
+
// send-task never passes contextKey explicitly, so the child inherits the
|
|
262
|
+
// parent's contextKey verbatim (createTaskExtended, src/be/db.ts:3556-3558).
|
|
263
|
+
const inheritedContextKey = effectiveParentTask?.contextKey;
|
|
264
|
+
const routingCheck = checkSlackRoutingCoherence({
|
|
265
|
+
explicit: { channelId: slackChannelId, threadTs: slackThreadTs, userId: slackUserId },
|
|
266
|
+
parent: effectiveParentTask,
|
|
267
|
+
inheritedContextKey,
|
|
268
|
+
});
|
|
269
|
+
if (routingCheck.verdict !== "ok") {
|
|
270
|
+
const msg =
|
|
271
|
+
routingCheck.verdict === "partial-unit"
|
|
272
|
+
? `Slack routing rejected: ${routingCheck.detail}`
|
|
273
|
+
: `Slack routing mismatch: you passed ${routingCheck.field}="${routingCheck.got}" but the ${routingCheck.expectedSource} task says "${routingCheck.expected}". Omit the three Slack fields to inherit them from the parent as a unit (preferred), or pass overrideSlackContext: true if the cross-channel routing is deliberate.`;
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text", text: msg }],
|
|
276
|
+
structuredContent: {
|
|
277
|
+
yourAgentId: creatorAgentId,
|
|
278
|
+
success: false,
|
|
279
|
+
message: msg,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
} else if (slackChannelId || slackThreadTs) {
|
|
284
|
+
console.log(
|
|
285
|
+
`[send-task] slack-context override: creatorAgentId=${creatorAgentId ?? "n/a"} slackChannelId=${slackChannelId ?? "n/a"} slackThreadTs=${slackThreadTs ?? "n/a"} parentTaskId=${effectiveParentTaskId ?? "n/a"}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
229
289
|
// Auto-route to parent's worker if parentTaskId is set and no explicit agentId
|
|
230
290
|
let effectiveAgentId = agentId;
|
|
231
291
|
if (effectiveParentTaskId && !agentId) {
|
|
@@ -345,6 +405,7 @@ export async function sendTaskHandler(
|
|
|
345
405
|
slackChannelId,
|
|
346
406
|
slackThreadTs,
|
|
347
407
|
slackUserId,
|
|
408
|
+
overrideSlackContext,
|
|
348
409
|
followUpConfig,
|
|
349
410
|
// Only meaningful here: a pool task's routingAffinity gates
|
|
350
411
|
// claimTask/autoAssignPoolTasks. offer/direct-assign below bypass the
|
|
@@ -412,6 +473,7 @@ export async function sendTaskHandler(
|
|
|
412
473
|
slackChannelId,
|
|
413
474
|
slackThreadTs,
|
|
414
475
|
slackUserId,
|
|
476
|
+
overrideSlackContext,
|
|
415
477
|
followUpConfig,
|
|
416
478
|
});
|
|
417
479
|
transferTrackerSyncToResumeChild({
|
|
@@ -446,6 +508,7 @@ export async function sendTaskHandler(
|
|
|
446
508
|
slackChannelId,
|
|
447
509
|
slackThreadTs,
|
|
448
510
|
slackUserId,
|
|
511
|
+
overrideSlackContext,
|
|
449
512
|
followUpConfig,
|
|
450
513
|
});
|
|
451
514
|
transferTrackerSyncToResumeChild({
|