@desplega.ai/agent-swarm 2.5.1 → 2.5.2

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 (155) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
  37. package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/http/all-routes.ts +1 -0
  102. package/src/http/approval-requests.ts +46 -6
  103. package/src/http/index.ts +2 -0
  104. package/src/http/schedules.ts +26 -0
  105. package/src/http/scripts.ts +2 -2
  106. package/src/http/sessions.ts +93 -2
  107. package/src/http/user-roles.ts +173 -0
  108. package/src/http/users.ts +9 -0
  109. package/src/http/x.ts +2 -2
  110. package/src/integrations/chat-sdk/commands.ts +14 -1
  111. package/src/integrations/chat-sdk/inbound.ts +40 -5
  112. package/src/integrations/chat-sdk/outbound.ts +35 -10
  113. package/src/integrations/chat-sdk/reactions.ts +107 -0
  114. package/src/integrations/chat-sdk/relay.ts +75 -7
  115. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  116. package/src/integrations/reaction-events.ts +20 -0
  117. package/src/providers/amp-adapter.ts +1 -1
  118. package/src/rbac/legacy-policy.ts +3 -0
  119. package/src/rbac/permissions.ts +8 -0
  120. package/src/scheduler/schedule-task.ts +44 -3
  121. package/src/scheduler/scheduler.ts +4 -4
  122. package/src/scripts-runtime/loader.ts +8 -1
  123. package/src/slack/ack.ts +2 -1
  124. package/src/slack/reaction-shortcode.ts +4 -7
  125. package/src/tasks/worker-follow-up.ts +30 -0
  126. package/src/tests/approval-requests-notifications.test.ts +189 -0
  127. package/src/tests/chat-sdk-commands.test.ts +12 -0
  128. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  129. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  130. package/src/tests/chat-sdk-relay.test.ts +361 -10
  131. package/src/tests/chat-sdk-tools.test.ts +104 -0
  132. package/src/tests/docs-index-footprint.test.js +3 -3
  133. package/src/tests/http-users.test.ts +38 -0
  134. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  135. package/src/tests/rbac-engine.test.ts +2 -0
  136. package/src/tests/rbac-roles.test.ts +10 -0
  137. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  138. package/src/tests/script-api-credential.test.ts +173 -0
  139. package/src/tests/script-connections.test.ts +4 -0
  140. package/src/tests/script-run-config-scope.test.ts +120 -0
  141. package/src/tests/script-run-token.test.ts +75 -0
  142. package/src/tests/scripts-http.test.ts +19 -0
  143. package/src/tests/sessions-tree.test.ts +242 -0
  144. package/src/tests/user-roles-http.test.ts +204 -0
  145. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  146. package/src/tools/chat-reply.ts +2 -0
  147. package/src/tools/schedules/patch-schedule.ts +18 -0
  148. package/src/tools/schedules/update-schedule.ts +18 -0
  149. package/src/types.ts +16 -0
  150. package/src/workflows/approval-notifications.ts +214 -0
  151. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  152. package/src/workflows/executors/swarm-script.ts +2 -2
  153. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  154. package/templates/skills/code-quality/content.md +1 -1
  155. package/dist/rbac-roles-ff1ffmec.js +0 -12
package/src/http/x.ts CHANGED
@@ -15,12 +15,12 @@ import {
15
15
  restoreScratchScriptLastUsedIfUnchanged,
16
16
  touchScratchScriptLastUsed,
17
17
  } from "../be/scripts/db";
18
+ import { runScriptWithRunCredential } from "../be/scripts/run-credential";
18
19
  import {
19
20
  MAX_SCRIPT_WALL_CLOCK_MS,
20
21
  MIN_SCRIPT_WALL_CLOCK_MS,
21
22
  } from "../scripts-runtime/executors/types";
22
23
  import type { RunScriptOutput } from "../scripts-runtime/loader";
23
- import { runScript } from "../scripts-runtime/loader";
24
24
  import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
25
25
  import { validateJsonSchema } from "../workflows/json-schema-validator";
26
26
  import { route } from "./route-def";
@@ -242,7 +242,7 @@ export async function handleX(
242
242
  const credentials = await buildScriptCredentialBindingsWithFailures({
243
243
  agentId: endpoint.agentId,
244
244
  });
245
- const output = await runScript({
245
+ const output = await runScriptWithRunCredential({
246
246
  source: script.source,
247
247
  args: args ?? null,
248
248
  fsMode: "none",
@@ -63,7 +63,19 @@ function helpText(): string {
63
63
  return ["Commands:", ...CHAT_COMMANDS.map((c) => `/${c.command}: ${c.description}`)].join("\n");
64
64
  }
65
65
 
66
- function shortId(task: AgentTask): string {
66
+ /**
67
+ * Telegram sends `/start` when a user opens a bot DM. It stays out of
68
+ * `CHAT_COMMANDS`, so the `/` menu does not list it.
69
+ */
70
+ function welcomeText(): string {
71
+ return [
72
+ "Hi! Send a message here (in a group, mention me) and the swarm picks it up. Each answer arrives as a reply to your message.",
73
+ "",
74
+ helpText(),
75
+ ].join("\n");
76
+ }
77
+
78
+ export function shortId(task: Pick<AgentTask, "id">): string {
67
79
  return task.id.slice(0, 8);
68
80
  }
69
81
 
@@ -115,6 +127,7 @@ export async function runChatCommand(
115
127
  ): Promise<string> {
116
128
  const name = command.replace(/^\//, "").toLowerCase();
117
129
  if (name === "help") return helpText();
130
+ if (name === "start") return welcomeText();
118
131
  if (!CHAT_COMMANDS.some((c) => c.command === name)) {
119
132
  return `Unknown command /${name}.\n${helpText()}`;
120
133
  }
@@ -32,6 +32,12 @@ import { getChatThreadResetAt } from "./commands";
32
32
  import { type ChatAddressedReason, formatChatHeaderFacts, readChatMessageFacts } from "./facts";
33
33
  import { errorMessage } from "./logger";
34
34
  import { emitChatMessageSent, postChatMessage } from "./outbound";
35
+ import {
36
+ chatAdapterReacts,
37
+ clearChatMessageReactions,
38
+ setChatMessageReaction,
39
+ wantsChatTextAck,
40
+ } from "./reactions";
35
41
  import {
36
42
  type ChatAgentMatch,
37
43
  type ChatRouteTarget,
@@ -154,9 +160,14 @@ export async function handleChatInbound(
154
160
  console.log(
155
161
  `[chat-sdk] ${adapter} message steered task ${steering.task.id} (${steering.result.outcome})`,
156
162
  );
163
+ await setChatMessageReaction(adapter, thread.id, message.id, "steered");
164
+ if (!(await wantsChatTextAck(adapter, thread.channelId))) return;
157
165
  try {
158
166
  const ack = formatChatSteeringAck(steering.result);
159
- const sent = await thread.post({ markdown: ack });
167
+ // Telegram threads the ack under the steering message; other adapters post it.
168
+ const sent = thread.adapter.reply
169
+ ? await thread.reply(message, { markdown: ack })
170
+ : await thread.post({ markdown: ack });
160
171
  emitChatMessageSent({
161
172
  adapter,
162
173
  channelId: thread.channelId,
@@ -177,6 +188,9 @@ export async function handleChatInbound(
177
188
  }
178
189
 
179
190
  if (text || attachments.length > 0) {
191
+ // Subscribed-thread messages may be dropped by the router at flush time;
192
+ // they get typing and a reaction once a task exists (see flushChatThread).
193
+ const acknowledge = kind !== "subscribed";
180
194
  bufferChatMessage(thread.id, {
181
195
  messageId: message.id,
182
196
  channelId: thread.channelId,
@@ -186,12 +200,14 @@ export async function handleChatInbound(
186
200
  botMentioned: mentioned || facts.replyToBot,
187
201
  isDirectMessage,
188
202
  addressed,
203
+ reacted: acknowledge && chatAdapterReacts(adapter),
189
204
  facts,
190
205
  attachments,
191
206
  });
192
- // Subscribed-thread messages may be dropped by the router at flush time;
193
- // they get typing once a task exists (see flushChatThread).
194
- if (kind !== "subscribed") startTypingKeepalive(adapter, thread.id);
207
+ if (acknowledge) {
208
+ startTypingKeepalive(adapter, thread.id);
209
+ await setChatMessageReaction(adapter, thread.id, message.id, immediate ? "now" : "accepted");
210
+ }
195
211
  }
196
212
  if (immediate) await instantFlushChat(thread.id);
197
213
  }
@@ -377,6 +393,9 @@ export async function flushChatThread(
377
393
  // One task carries every sender's text, so a batch from several people
378
394
  // gets no requester: one sender's identity must not vouch for another's words.
379
395
  const otherSenders = new Set(items.map((item) => item.userId)).size - 1;
396
+ // The task answers the newest message; it alone carries the reaction lifecycle.
397
+ const answered = items.at(-1) ?? first;
398
+ const reacted = items.filter((item) => item.reacted).map((item) => item.messageId);
380
399
 
381
400
  // After `/reset` the thread's earlier tasks are not context: the new task gets
382
401
  // no parent and no thread history older than the reset.
@@ -418,6 +437,7 @@ export async function flushChatThread(
418
437
  scrubSecrets(routeResult.reason),
419
438
  );
420
439
  stopTypingKeepalive(threadId);
440
+ await clearChatMessageReactions(adapter, threadId, reacted);
421
441
  return;
422
442
  }
423
443
 
@@ -433,12 +453,14 @@ export async function flushChatThread(
433
453
  if (!target) {
434
454
  console.log(`[chat-sdk] ${threadId}: message not addressed to the swarm, ignored`);
435
455
  stopTypingKeepalive(threadId);
456
+ await clearChatMessageReactions(adapter, threadId, reacted);
436
457
  return;
437
458
  }
438
459
 
439
460
  if (target.kind === "workflow") {
440
- // A workflow run has no relay that would stop the indicator.
461
+ // A workflow run has no relay that would stop the indicator or end the reaction.
441
462
  stopTypingKeepalive(threadId);
463
+ await clearChatMessageReactions(adapter, threadId, reacted);
442
464
  try {
443
465
  // The adapter verified the platform signature, and the workflow id comes
444
466
  // from an operator mapping or an extension, never from the message.
@@ -533,6 +555,11 @@ export async function flushChatThread(
533
555
  // A human message in the thread: never answer it in the previous task's
534
556
  // structured-output contract.
535
557
  inheritParentOutputSchema: false,
558
+ // The relay answers as a reply to the newest buffered message. Always set
559
+ // explicitly: `createTask` copies the parent's anchor into a child that
560
+ // has none, and that would point at an older ask.
561
+ mentionMessageId: answered.messageId,
562
+ mentionChannelId: first.channelId,
536
563
  };
537
564
  try {
538
565
  const { task, unattached } = await createChatTaskWithFiles(description, options, inbound);
@@ -555,8 +582,16 @@ export async function flushChatThread(
555
582
  await subscribe(thread);
556
583
  // Subscribed-thread messages start typing only here, once a task exists.
557
584
  startTypingKeepalive(adapter, threadId);
585
+ await clearChatMessageReactions(
586
+ adapter,
587
+ threadId,
588
+ reacted.filter((id) => id !== answered.messageId),
589
+ );
590
+ if (!answered.reacted)
591
+ await setChatMessageReaction(adapter, threadId, answered.messageId, "accepted");
558
592
  } else {
559
593
  stopTypingKeepalive(threadId);
594
+ await clearChatMessageReactions(adapter, threadId, reacted);
560
595
  }
561
596
  }
562
597
 
@@ -76,12 +76,14 @@ export class ChatFileTooLargeError extends Error {
76
76
  }
77
77
  }
78
78
 
79
+ type SentChunk = { id: string; threadId?: string };
80
+
79
81
  interface PostTarget {
80
82
  post(message: {
81
83
  markdown: string;
82
84
  files?: FileUpload[];
83
85
  attachments?: Attachment[];
84
- }): Promise<{ id: string; threadId?: string }>;
86
+ }): Promise<SentChunk>;
85
87
  }
86
88
 
87
89
  /** A fence opener or closer: up to 3 spaces, then 3+ backticks or tildes. */
@@ -120,16 +122,22 @@ export function escapeTelegramHashLines(markdown: string): string {
120
122
  .join("\n");
121
123
  }
122
124
 
125
+ /** `replyFirst` sends the first chunk as a native reply; later chunks are plain posts. */
123
126
  async function postChunks(
124
127
  adapter: string,
125
128
  target: PostTarget,
126
- text: string,
129
+ parts: readonly string[],
130
+ replyFirst?: (message: { markdown: string }) => Promise<SentChunk>,
127
131
  ): Promise<{ messageIds: string[]; threadId?: string }> {
128
132
  const messageIds: string[] = [];
129
133
  let threadId: string | undefined;
130
- for (const chunk of splitChatMessage(text, chatMessageLimit(adapter))) {
131
- const markdown = adapter === "telegram" ? escapeTelegramHashLines(chunk) : chunk;
132
- const sent = await target.post({ markdown });
134
+ const limit = chatMessageLimit(adapter);
135
+ for (const chunk of parts.flatMap((part) => splitChatMessage(part, limit))) {
136
+ const message = { markdown: adapter === "telegram" ? escapeTelegramHashLines(chunk) : chunk };
137
+ const sent =
138
+ replyFirst && messageIds.length === 0
139
+ ? await replyFirst(message)
140
+ : await target.post(message);
133
141
  messageIds.push(sent.id);
134
142
  threadId ??= sent.threadId;
135
143
  }
@@ -323,28 +331,45 @@ export function formatUnsentFilesNote(unsent: UnsentChatFile[]): string {
323
331
  * Delivery is at-least-once: a chunk failure re-posts earlier chunks on retry,
324
332
  * and a crash between the send and `markChatRelayDelivered` re-sends the
325
333
  * message.
334
+ *
335
+ * An array `text` is posted as one message per element, each still split at
336
+ * the limit; the caller uses it when its own markup must not be cut in two.
337
+ *
338
+ * `replyToMessageId` anchors the first chunk to that message on a thread
339
+ * target. Telegram sends it with `allow_sending_without_reply`, so a deleted
340
+ * target still gets the message, unthreaded.
326
341
  */
327
342
  export async function postChatMessage(args: {
328
343
  adapter: string;
329
344
  threadId?: string;
330
345
  channelId?: string;
331
- text: string;
346
+ text: string | readonly string[];
332
347
  taskId?: string;
333
348
  files?: OutboundChatFile[];
349
+ replyToMessageId?: string;
334
350
  }): Promise<ChatPostResult> {
335
351
  // Ids come from tools and stored context keys. Check the Teams origin
336
352
  // before the adapter sends its token.
337
353
  assertTrustedChatTarget(args.threadId ?? args.channelId ?? "");
338
354
  const chat = getChatInstance(args.adapter);
339
355
  if (!chat) throw new ChatAdapterUnavailableError(args.adapter);
340
- const text = scrubSecrets(args.text);
356
+ const parts = (typeof args.text === "string" ? [args.text] : args.text).map(scrubSecrets);
357
+ const text = parts.join("\n\n");
341
358
 
342
359
  let result: ChatPostResult;
343
360
  let target: PostTarget;
344
361
  if (args.threadId) {
345
362
  const thread = chat.thread(args.threadId);
346
363
  target = thread;
347
- const sent = await postChunks(args.adapter, thread, text);
364
+ // Only adapters with a native reply (Telegram in this SDK version) anchor;
365
+ // the others post unanchored, as before.
366
+ const replyTo = args.replyToMessageId;
367
+ const sent = await postChunks(
368
+ args.adapter,
369
+ thread,
370
+ parts,
371
+ replyTo && thread.adapter.reply ? (message) => thread.reply(replyTo, message) : undefined,
372
+ );
348
373
  result = {
349
374
  adapter: args.adapter,
350
375
  threadId: thread.id,
@@ -355,7 +380,7 @@ export async function postChatMessage(args: {
355
380
  } else if (args.channelId) {
356
381
  const channel = chat.channel(args.channelId);
357
382
  target = channel;
358
- const sent = await postChunks(args.adapter, channel, text);
383
+ const sent = await postChunks(args.adapter, channel, parts);
359
384
  result = {
360
385
  adapter: args.adapter,
361
386
  threadId: sent.threadId ?? channel.id,
@@ -373,7 +398,7 @@ export async function postChatMessage(args: {
373
398
  result.unsentFiles = sent.unsent;
374
399
  if (sent.unsent.length > 0) {
375
400
  const note = formatUnsentFilesNote(sent.unsent);
376
- const noted = await postChunks(args.adapter, target, scrubSecrets(note));
401
+ const noted = await postChunks(args.adapter, target, [scrubSecrets(note)]);
377
402
  result.messageIds.push(...noted.messageIds);
378
403
  }
379
404
  }
@@ -0,0 +1,107 @@
1
+ import { getKv } from "@/be/db";
2
+ import type { ReactionEvent } from "@/integrations/reaction-events";
3
+ import { scrubSecrets } from "@/utils/secret-scrubber";
4
+ import { errorMessage } from "./logger";
5
+ import { chatChannelKey } from "./router";
6
+ import { getChatInstance } from "./runtime";
7
+ import { assertTrustedChatTarget } from "./teams-origin";
8
+
9
+ /**
10
+ * Telegram reactions per lifecycle event. A bot may set only the emoji in the
11
+ * Bot API `ReactionTypeEmoji` list (https://core.telegram.org/bots/api#reactiontypeemoji),
12
+ * which has no ✅ or ❌. `✍` is U+270D with no variation selector, as listed.
13
+ * A bot sets one reaction per message, so each call replaces the previous one.
14
+ */
15
+ export const TELEGRAM_REACTIONS: Record<ReactionEvent, string> = {
16
+ accepted: "👀",
17
+ buffered: "👀",
18
+ now: "⚡",
19
+ steered: "✍",
20
+ completed: "👍",
21
+ failed: "👎",
22
+ };
23
+
24
+ /** Adapters that show the task lifecycle as a reaction on the user's message. */
25
+ const CHAT_REACTIONS: Readonly<Record<string, Record<ReactionEvent, string>>> = {
26
+ telegram: TELEGRAM_REACTIONS,
27
+ };
28
+
29
+ /** True when `adapter` shows the lifecycle as reactions instead of text acknowledgements. */
30
+ export function chatAdapterReacts(adapter: string): boolean {
31
+ return adapter in CHAT_REACTIONS;
32
+ }
33
+
34
+ /**
35
+ * Set the reaction for `event` on one message. Best effort: an adapter without
36
+ * a reaction map is a no-op, and a platform error (Telegram answers
37
+ * `400 REACTION_INVALID` for an emoji a group admin disallowed) is logged and
38
+ * never thrown, so it cannot block delivery or fail the task.
39
+ */
40
+ export async function setChatMessageReaction(
41
+ adapter: string,
42
+ threadId: string,
43
+ messageId: string,
44
+ event: ReactionEvent,
45
+ ): Promise<void> {
46
+ const emoji = CHAT_REACTIONS[adapter]?.[event];
47
+ if (!emoji) return;
48
+ try {
49
+ const target = getChatInstance(adapter)?.getAdapter(adapter);
50
+ if (!target) return;
51
+ assertTrustedChatTarget(threadId);
52
+ await target.addReaction(threadId, messageId, emoji);
53
+ } catch (error) {
54
+ console.warn(
55
+ scrubSecrets(
56
+ `[chat-sdk] Could not set the ${event} reaction on ${messageId} in ${threadId}: ${errorMessage(error)}`,
57
+ ),
58
+ );
59
+ }
60
+ }
61
+
62
+ /** Remove the bot's reaction from each message. Best effort, like {@link setChatMessageReaction}. */
63
+ export async function clearChatMessageReactions(
64
+ adapter: string,
65
+ threadId: string,
66
+ messageIds: readonly string[],
67
+ ): Promise<void> {
68
+ if (!chatAdapterReacts(adapter) || messageIds.length === 0) return;
69
+ const target = getChatInstance(adapter)?.getAdapter(adapter);
70
+ if (!target) return;
71
+ for (const messageId of messageIds) {
72
+ try {
73
+ // Telegram clears the bot's whole reaction whatever emoji is named.
74
+ await target.removeReaction(threadId, messageId, TELEGRAM_REACTIONS.accepted);
75
+ } catch (error) {
76
+ console.warn(
77
+ scrubSecrets(
78
+ `[chat-sdk] Could not clear the reaction on ${messageId} in ${threadId}: ${errorMessage(error)}`,
79
+ ),
80
+ );
81
+ }
82
+ }
83
+ }
84
+
85
+ /** KV namespace of per-channel Chat SDK settings, keyed like the channel mappings. */
86
+ export const CHAT_CHANNEL_SETTINGS_NAMESPACE = "integrations:chat:channel-settings";
87
+
88
+ export interface ChatChannelSettings {
89
+ /** Also post the text steering acknowledgement on an adapter that reacts. */
90
+ textAcks?: boolean;
91
+ }
92
+
93
+ /**
94
+ * Whether a steering message gets a text acknowledgement. Adapters without
95
+ * reactions always get one; a reacting adapter only when its channel opts in.
96
+ */
97
+ export async function wantsChatTextAck(adapter: string, channelId: string): Promise<boolean> {
98
+ if (!chatAdapterReacts(adapter)) return true;
99
+ try {
100
+ const row = await getKv(CHAT_CHANNEL_SETTINGS_NAMESPACE, chatChannelKey(adapter, channelId));
101
+ const settings = row?.value as ChatChannelSettings | null | undefined;
102
+ return typeof settings === "object" && settings !== null && settings.textAcks === true;
103
+ } catch (error) {
104
+ console.warn(`[chat-sdk] Could not read the channel settings: ${errorMessage(error)}`);
105
+ return false;
106
+ }
107
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ getAgentById,
2
3
  getPendingChatRelayTasks,
3
4
  giveUpStaleChatRelays,
4
5
  markChatRelayAttempted,
@@ -6,13 +7,17 @@ import {
6
7
  markChatRelayGivenUp,
7
8
  recordChatRelayError,
8
9
  } from "@/be/db";
10
+ import { terminalReactionEvent } from "@/integrations/reaction-events";
9
11
  import { chatThreadOfContextKey } from "@/tasks/context-key";
10
12
  import type { AgentTask } from "@/types";
11
13
  import { scrubSecrets } from "@/utils/secret-scrubber";
12
14
  import { workflowEventBus } from "@/workflows/event-bus";
15
+ import { shortId } from "./commands";
16
+ import { chatMessageLimit, splitChatMessage } from "./limits";
13
17
  import { errorMessage } from "./logger";
14
18
  import { postChatMessage } from "./outbound";
15
19
  import { taskOutputChatFiles } from "./outbound-files";
20
+ import { setChatMessageReaction } from "./reactions";
16
21
  import { getChatInstance, getChatSdkHealth } from "./runtime";
17
22
  import { sweepExpired } from "./state";
18
23
  import { stopTypingKeepalive } from "./typing";
@@ -33,14 +38,64 @@ const SWEEP_EVERY_N_TICKS = 20;
33
38
  /** Coalesce bursts of terminal-task events into one early tick. */
34
39
  const EARLY_TICK_DEBOUNCE_MS = 250;
35
40
 
36
- /** The text posted for a terminal task. Failed and cancelled tasks get a status line first. */
37
- export function formatChatRelayText(task: AgentTask): string {
41
+ /**
42
+ * The first line of every relayed answer: the short task id `/status` shows
43
+ * and the agent's name, e.g. `#a3f2b1c9 · Coder`. Two answers in one chat stay
44
+ * apart even when the platform cannot show what they reply to.
45
+ */
46
+ export function formatChatTaskTag(task: Pick<AgentTask, "id">, agentName?: string): string {
47
+ return agentName ? `#${shortId(task)} · ${agentName}` : `#${shortId(task)}`;
48
+ }
49
+
50
+ /** The task tag, an optional status line, and the answer itself. */
51
+ function relayHeadAndBody(task: AgentTask, agentName?: string): { head: string; body: string } {
52
+ const tag = formatChatTaskTag(task, agentName);
38
53
  const body = (task.output || task.failureReason || "").trim();
39
- if (task.status === "failed") return body ? `**Task failed.**\n\n${body}` : "**Task failed.**";
40
- if (task.status === "cancelled") {
41
- return body ? `**Task cancelled.**\n\n${body}` : "**Task cancelled.**";
54
+ if (task.status === "failed") return { head: `${tag}\n**Task failed.**`, body };
55
+ if (task.status === "cancelled") return { head: `${tag}\n**Task cancelled.**`, body };
56
+ return { head: tag, body: body || "Task completed." };
57
+ }
58
+
59
+ /** The text posted for a terminal task. Failed and cancelled tasks get a status line after the tag. */
60
+ export function formatChatRelayText(task: AgentTask, agentName?: string): string {
61
+ const { head, body } = relayHeadAndBody(task, agentName);
62
+ return body ? `${head}\n\n${body}` : head;
63
+ }
64
+
65
+ /** On Telegram, an answer body longer than this is posted collapsed. */
66
+ export const TELEGRAM_FOLD_CHARS = 1000;
67
+ /** A first paragraph up to this long stays visible above the collapsed rest. */
68
+ const FOLD_LEAD_MAX_CHARS = 280;
69
+ /** Room for the `<details>` markup and separators around one folded piece. */
70
+ const FOLD_MARKUP_CHARS = 80;
71
+
72
+ /**
73
+ * The messages posted for a terminal task. On Telegram a long body goes into
74
+ * collapsed `<details>` blocks: Bot API 10.1 rich messages render them folded
75
+ * and parse the markdown inside. Each message gets its own block, so the
76
+ * length split never cuts the markup in two.
77
+ */
78
+ export function formatChatRelayMessages(
79
+ task: AgentTask,
80
+ adapter: string,
81
+ agentName?: string,
82
+ ): string[] {
83
+ const { head, body } = relayHeadAndBody(task, agentName);
84
+ if (adapter !== "telegram" || body.length <= TELEGRAM_FOLD_CHARS) {
85
+ return [formatChatRelayText(task, agentName)];
42
86
  }
43
- return body || "Task completed.";
87
+ const breakAt = body.indexOf("\n\n");
88
+ const lead = breakAt > 0 && breakAt <= FOLD_LEAD_MAX_CHARS ? body.slice(0, breakAt) : "";
89
+ const rest = lead ? body.slice(breakAt).trim() : body;
90
+ const top = lead ? `${head}\n\n${lead}` : head;
91
+ const room = chatMessageLimit(adapter) - top.length - FOLD_MARKUP_CHARS;
92
+ const pieces = splitChatMessage(rest, room);
93
+ return pieces.map((piece, index) => {
94
+ const label = lead ? "Rest of the answer" : "Full answer";
95
+ const summary = pieces.length > 1 ? `${label} (${index + 1}/${pieces.length})` : label;
96
+ const block = `<details><summary>${summary}</summary>\n\n${piece}\n\n</details>`;
97
+ return index === 0 ? `${top}\n\n${block}` : block;
98
+ });
44
99
  }
45
100
 
46
101
  /**
@@ -96,16 +151,29 @@ export async function processPendingChatRelays(
96
151
 
97
152
  if (!(await markChatRelayAttempted(task.id, now))) continue;
98
153
  try {
154
+ const agent = task.agentId ? await getAgentById(task.agentId) : null;
99
155
  await postChatMessage({
100
156
  adapter: target.adapter,
101
157
  threadId: target.threadId,
102
- text: formatChatRelayText(task),
158
+ text: formatChatRelayMessages(task, target.adapter, agent?.name),
103
159
  taskId: task.id,
104
160
  files: await taskOutputChatFiles(task.id),
161
+ // The inbound message this task answers. A lead follow-up, resume or
162
+ // defer wake-up inherits it from its parent, so it answers the same ask.
163
+ replyToMessageId: task.mentionMessageId,
105
164
  });
106
165
  stopTypingKeepalive(target.threadId);
107
166
  await markChatRelayDelivered(task.id, now);
108
167
  console.log(`[chat-sdk] Relayed ${task.status} task ${task.id} to ${target.adapter}`);
168
+ // The ask's reaction ends with the answer. Never throws: delivery already landed.
169
+ if (task.mentionMessageId) {
170
+ await setChatMessageReaction(
171
+ target.adapter,
172
+ target.threadId,
173
+ task.mentionMessageId,
174
+ terminalReactionEvent(task.status),
175
+ );
176
+ }
109
177
  } catch (error) {
110
178
  const message = errorMessage(error);
111
179
  await recordChatRelayError(task.id, message, now);
@@ -17,6 +17,8 @@ export interface ChatBufferedMessage {
17
17
  isDirectMessage: boolean;
18
18
  /** Why the message was taken; `none` when it is unaddressed. */
19
19
  addressed: ChatAddressedReason;
20
+ /** The message got the `accepted` reaction when it was buffered. */
21
+ reacted?: boolean;
20
22
  facts: ChatMessageFacts;
21
23
  attachments: Attachment[];
22
24
  }
@@ -0,0 +1,20 @@
1
+ import type { AgentTaskStatus } from "@/types";
2
+
3
+ /**
4
+ * The task lifecycle a chat channel shows as a reaction on the user's message.
5
+ * Each channel maps these events to its own emoji: Slack in
6
+ * `src/slack/reaction-shortcode.ts`, Chat SDK adapters in
7
+ * `src/integrations/chat-sdk/reactions.ts`.
8
+ *
9
+ * - `accepted`: the swarm took the message.
10
+ * - `buffered`: the message joined a batch that is still waiting.
11
+ * - `now`: the message skipped the buffer (`!now`).
12
+ * - `steered`: the message went to a running task, or was queued for it.
13
+ * - `completed` / `failed`: the task that answers the message ended.
14
+ */
15
+ export type ReactionEvent = "accepted" | "buffered" | "now" | "steered" | "completed" | "failed";
16
+
17
+ /** The terminal event for an ended task: anything but `completed` shows as `failed`. */
18
+ export function terminalReactionEvent(status: AgentTaskStatus): "completed" | "failed" {
19
+ return status === "completed" ? "completed" : "failed";
20
+ }
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792670445-ge8d5b3";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792728062-g40cf3f";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -351,6 +351,7 @@ export const LEGACY_POLICY = {
351
351
  "script.api.update": leadOnly,
352
352
  "script.api.rotate": leadOnly,
353
353
  "script.api.delete": leadOnly,
354
+ "schedule.deferral.update.any": leadOrResourceOwner,
354
355
  // Pool writes pick the image that receives a runtime token, so only humans
355
356
  // may make them. The lead may only place agents into operator-made pools.
356
357
  "runtime_pool.write": operatorOrUser,
@@ -358,6 +359,8 @@ export const LEGACY_POLICY = {
358
359
  "runtime_controller.write": operatorOrUser,
359
360
  // A worker token stands in for the swarm API key on a worker, so only humans mint it.
360
361
  "agent.token.manage": operatorOrUser,
362
+ // Agents never change human roles.
363
+ "user.role.manage": operatorOrUser,
361
364
  "extension.write": extensionWrite,
362
365
  "extension.activate": extensionActivation,
363
366
  "extension.install.inline": leadOrOperatorOrUser,
@@ -37,6 +37,10 @@ export const PERMISSIONS = {
37
37
  description: "Mint, rotate, or revoke an agent-bound worker token.",
38
38
  namespace: "agent",
39
39
  },
40
+ "user.role.manage": {
41
+ description: "Assign or remove a person's RBAC roles.",
42
+ namespace: "user",
43
+ },
40
44
  "task.cancel.any": {
41
45
  description: "Cancel any task (beyond tasks the caller created).",
42
46
  namespace: "task",
@@ -354,6 +358,10 @@ export const PERMISSIONS = {
354
358
  description: "Delete an external script API endpoint.",
355
359
  namespace: "script",
356
360
  },
361
+ "schedule.deferral.update.any": {
362
+ description: "Change a defer-task schedule the caller did not create.",
363
+ namespace: "schedule",
364
+ },
357
365
  "runtime_pool.write": {
358
366
  description: "Create, update, or delete runtime pools (image, provider kind, placement).",
359
367
  namespace: "runtime_pool",
@@ -1,12 +1,53 @@
1
- import { createTaskExtended, getDbClient } from "@/be/db";
1
+ import { createTaskExtended, getAgentById, getDbClient, getTaskById } from "@/be/db";
2
2
  import { carryUnhandledSteering } from "@/be/steering";
3
- import { scheduleContextKey } from "@/tasks/context-key";
3
+ import { chatThreadOfContextKey, scheduleContextKey } from "@/tasks/context-key";
4
4
  import {
5
5
  type PreparedTaskCreate,
6
6
  prepareTaskWithSiblingAwareness,
7
7
  } from "@/tasks/sibling-awareness";
8
8
  import type { AgentTask, ScheduledTask } from "@/types";
9
9
 
10
+ /**
11
+ * Whether the task's own answer is posted to its chat thread. Mirrors the
12
+ * relay triggers (migration 235): a delegated worker task, or its resume,
13
+ * reports through the Lead follow-up instead.
14
+ */
15
+ async function answersInChat(task: AgentTask): Promise<boolean> {
16
+ if (task.source === "chat") return true;
17
+ if (task.source === "system" && task.taskType === "follow-up") return true;
18
+ // Only a wake-up that passed `scheduleTaskContextKey` has a chat key.
19
+ if (task.source === "schedule" && task.taskType === "deferred") return true;
20
+ if (task.source === "system" && task.taskType === "resume" && task.agentId) {
21
+ return (await getAgentById(task.agentId))?.isLead === true;
22
+ }
23
+ return false;
24
+ }
25
+
26
+ /**
27
+ * A defer-task wake-up of a chat task stays in its chat thread, so the chat
28
+ * relay posts the real answer there (and `/status` and `/cancel` see it).
29
+ * Only the deferring agent continuing its own task gets the thread: a schedule
30
+ * re-targeted or re-owned after the deferral, or the deferral of a task that
31
+ * never answered in chat, fires like any other schedule. Every other firing
32
+ * gets the schedule's own key.
33
+ */
34
+ async function scheduleTaskContextKey(schedule: ScheduledTask): Promise<string> {
35
+ if (schedule.taskType === "deferred" && schedule.parentTaskId) {
36
+ const parent = await getTaskById(schedule.parentTaskId);
37
+ if (
38
+ parent?.contextKey &&
39
+ chatThreadOfContextKey(parent.contextKey) &&
40
+ parent.agentId &&
41
+ schedule.targetAgentId === parent.agentId &&
42
+ schedule.createdByAgentId === parent.agentId &&
43
+ (await answersInChat(parent))
44
+ ) {
45
+ return parent.contextKey;
46
+ }
47
+ }
48
+ return scheduleContextKey({ scheduleId: schedule.id });
49
+ }
50
+
10
51
  /**
11
52
  * Run the `pre.task.create` extension hooks for a schedule firing. Call this OUTSIDE
12
53
  * the transaction that creates the task: the dispatcher skips hooks while a
@@ -35,7 +76,7 @@ export async function prepareStandaloneScheduleTask(
35
76
  scheduleId: schedule.id,
36
77
  source: "schedule",
37
78
  requestedByUserId: schedule.createdBy,
38
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
79
+ contextKey: await scheduleTaskContextKey(schedule),
39
80
  // Set only by `defer-task`. An explicit parent wins over the sibling-awareness
40
81
  // auto-wiring (see withSiblingAwareness in src/tasks/sibling-awareness.ts), so
41
82
  // the wake-up run continues the deferred task rather than a random sibling.