@desplega.ai/agent-swarm 1.98.0 → 1.99.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 +1 -0
- package/openapi.json +20 -1
- package/package.json +5 -5
- package/src/be/memory/link-resolver.ts +226 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -2
- package/src/be/memory/raters/retrieval.ts +15 -4
- package/src/be/memory/raters/store.ts +4 -2
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
- package/src/be/modelsdev-cache.ts +5 -0
- package/src/be/pricing-refresh.ts +189 -0
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-pricing.ts +5 -3
- package/src/commands/profile-sync.ts +83 -17
- package/src/commands/runner.ts +35 -3
- package/src/e2b/dispatch.ts +5 -0
- package/src/hooks/hook.ts +21 -5
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +116 -7
- package/src/providers/claude-adapter.ts +13 -2
- package/src/providers/pricing-sources.md +27 -9
- package/src/providers/types.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +5 -1
- package/src/scripts-runtime/types/stdlib.d.ts +2 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
- package/src/server.ts +2 -0
- package/src/slack/blocks.ts +58 -12
- package/src/slack/responses.ts +35 -12
- package/src/slack/watcher.ts +28 -7
- package/src/tests/internal-ai/complete-structured.test.ts +34 -1
- package/src/tests/memory-http-recall-gating.test.ts +172 -0
- package/src/tests/memory-link-resolver.test.ts +92 -0
- package/src/tests/opencode-adapter.test.ts +3 -0
- package/src/tests/pricing-refresh.test.ts +156 -0
- package/src/tests/profile-sync.test.ts +186 -0
- package/src/tests/scripts-mcp-e2e.test.ts +1 -1
- package/src/tests/slack-blocks.test.ts +48 -1
- package/src/tools/memory-get.ts +22 -1
- package/src/tools/memory-search.ts +8 -1
- package/src/tools/utils.ts +10 -0
- package/src/types.ts +2 -0
- package/src/utils/internal-ai/complete-structured.ts +10 -1
- package/tsconfig.json +1 -0
|
@@ -248,6 +248,7 @@ export function mergeMcpConfig(
|
|
|
248
248
|
baseConfig: { mcpServers?: Record<string, unknown> } | null,
|
|
249
249
|
installedServers: Record<string, Record<string, unknown>> | null,
|
|
250
250
|
taskId: string,
|
|
251
|
+
contextKey?: string,
|
|
251
252
|
): { mcpServers: Record<string, unknown> } {
|
|
252
253
|
const config: { mcpServers: Record<string, unknown> } = {
|
|
253
254
|
mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
|
|
@@ -273,6 +274,9 @@ export function mergeMcpConfig(
|
|
|
273
274
|
const server = config.mcpServers[serverKey] as Record<string, unknown>;
|
|
274
275
|
if (!server.headers) server.headers = {};
|
|
275
276
|
(server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
|
|
277
|
+
if (contextKey) {
|
|
278
|
+
(server.headers as Record<string, string>)["X-Context-Key"] = contextKey;
|
|
279
|
+
}
|
|
276
280
|
}
|
|
277
281
|
|
|
278
282
|
return config;
|
|
@@ -291,6 +295,7 @@ export async function createSessionMcpConfig(
|
|
|
291
295
|
cwd: string,
|
|
292
296
|
taskId: string,
|
|
293
297
|
installedServers?: Record<string, Record<string, unknown>> | null,
|
|
298
|
+
contextKey?: string,
|
|
294
299
|
): Promise<string | null> {
|
|
295
300
|
// Collect every .mcp.json from cwd up to filesystem root. Stopping at the first
|
|
296
301
|
// match silently drops the swarm-managed /workspace/.mcp.json when the cloned
|
|
@@ -341,7 +346,12 @@ export async function createSessionMcpConfig(
|
|
|
341
346
|
}
|
|
342
347
|
|
|
343
348
|
try {
|
|
344
|
-
const config = mergeMcpConfig(
|
|
349
|
+
const config = mergeMcpConfig(
|
|
350
|
+
{ mcpServers: mergedServers },
|
|
351
|
+
installedServers ?? null,
|
|
352
|
+
taskId,
|
|
353
|
+
contextKey,
|
|
354
|
+
);
|
|
345
355
|
const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
|
|
346
356
|
await writeFile(sessionConfigPath, JSON.stringify(config, null, 2));
|
|
347
357
|
return sessionConfigPath;
|
|
@@ -950,11 +960,12 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
950
960
|
);
|
|
951
961
|
}
|
|
952
962
|
|
|
953
|
-
// Create per-session MCP config with X-Source-Task-Id
|
|
963
|
+
// Create per-session MCP config with X-Source-Task-Id + X-Context-Key headers + installed servers (no shared-file race condition)
|
|
954
964
|
const sessionMcpConfig = await createSessionMcpConfig(
|
|
955
965
|
config.cwd,
|
|
956
966
|
config.taskId,
|
|
957
967
|
installedServers,
|
|
968
|
+
config.contextKey,
|
|
958
969
|
);
|
|
959
970
|
|
|
960
971
|
// Stage the system prompt on disk so it can be passed as a file path
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
# Pricing sources
|
|
2
2
|
|
|
3
|
-
This page lists the sources that feed the `pricing` table
|
|
4
|
-
|
|
3
|
+
This page lists the sources that feed the `pricing` table. Operators bumping a
|
|
4
|
+
rate by hand should also update this file.
|
|
5
5
|
|
|
6
|
-
## Primary:
|
|
6
|
+
## Primary pricing freshness: runtime models.dev refresh
|
|
7
7
|
|
|
8
|
-
- **
|
|
8
|
+
- **Runtime module**: `src/be/pricing-refresh.ts`
|
|
9
|
+
- **Upstream**: `https://models.dev/api.json`, fetched with `If-None-Match`.
|
|
10
|
+
- **Boot wiring**: after `seedPricingFromModelsDev()`, the API server starts one
|
|
11
|
+
non-blocking refresh and then repeats every 12 hours with `setInterval`.
|
|
12
|
+
- **Update rule**: project upstream through `buildModelsDevSeedRows()` and insert
|
|
13
|
+
a new `effective_from=Date.now()` row only when the model/token class is new
|
|
14
|
+
or the active price changed. Identical prices are no-ops.
|
|
15
|
+
- **Growth bound**: after each refresh, keep only the latest two rows per
|
|
16
|
+
`(provider, model, token_class)` triple.
|
|
17
|
+
- **Pinned local entries**: safe by construction. The runtime refresh only adds
|
|
18
|
+
pricing rows; it does not rewrite or delete the committed snapshot.
|
|
19
|
+
|
|
20
|
+
## Fallback/UI catalog: vendored models.dev snapshot
|
|
21
|
+
|
|
22
|
+
- **Fallback path**: `src/be/modelsdev-cache.json`
|
|
9
23
|
- **UI compatibility path**: `ui/src/lib/modelsdev-cache.json` symlinks to the
|
|
10
24
|
backend snapshot so existing UI imports keep working.
|
|
11
25
|
- **Loaded by**: `src/be/modelsdev-cache.ts` → `src/be/seed-pricing.ts` →
|
|
12
26
|
`seedPricingFromModelsDev()`,
|
|
13
27
|
called from `src/server.ts` after `initDb`.
|
|
28
|
+
- **Role**: cold-start fallback seed for pricing when models.dev is unavailable,
|
|
29
|
+
plus the UI model-picker source for names, labels, and context windows.
|
|
14
30
|
- **Projection rules** (see the same module for code-level detail):
|
|
15
31
|
- Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
|
|
16
32
|
Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
|
|
@@ -22,12 +38,13 @@ Operators bumping a rate by hand should also update this file.
|
|
|
22
38
|
stripped name and the full `google/...` id) so internal-ai callers find
|
|
23
39
|
a hit either way.
|
|
24
40
|
|
|
25
|
-
- **
|
|
41
|
+
- **Snapshot refresh procedure**:
|
|
26
42
|
- Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
|
|
27
43
|
script). It fetches the latest snapshot from models.dev, diffs against
|
|
28
44
|
the vendored copy, prints a summary, and writes the new file.
|
|
29
45
|
- Commit the regenerated `src/be/modelsdev-cache.json` together with a bump
|
|
30
|
-
note in the PR description.
|
|
46
|
+
note in the PR description. This is no longer the pricing freshness path;
|
|
47
|
+
use it when the fallback/UI catalog needs new labels or context-window data.
|
|
31
48
|
|
|
32
49
|
## Manual overrides
|
|
33
50
|
|
|
@@ -50,6 +67,7 @@ no input/output pricing rows at the lookup time, the row is persisted with
|
|
|
50
67
|
`costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
|
|
51
68
|
yellow badge.
|
|
52
69
|
|
|
53
|
-
To fix:
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
To fix: first check whether the runtime refresh is failing. If the model must
|
|
71
|
+
also appear in the UI picker or cold-start fallback, add it to
|
|
72
|
+
`src/be/modelsdev-cache.json`; otherwise add a manual override row via the
|
|
73
|
+
existing admin route `POST /api/pricing`.
|
package/src/providers/types.ts
CHANGED
|
@@ -92,6 +92,7 @@ export interface ProviderSessionConfig {
|
|
|
92
92
|
apiKey: string;
|
|
93
93
|
cwd: string;
|
|
94
94
|
vcsRepo?: string;
|
|
95
|
+
contextKey?: string;
|
|
95
96
|
/**
|
|
96
97
|
* @deprecated Never set by the runner — native session resume was removed in
|
|
97
98
|
* the 2026-05-28 plan. Adapters log + ignore any stray value. Follow-up
|
|
@@ -56,7 +56,11 @@ function bridgeRequestFor(name: string, args: unknown): BridgeRequest | null {
|
|
|
56
56
|
case "memory_get": {
|
|
57
57
|
const memoryId = typeof body.memoryId === "string" ? body.memoryId : undefined;
|
|
58
58
|
if (!memoryId) throw new Error("memory_get requires string `memoryId`");
|
|
59
|
-
|
|
59
|
+
const getIntent = typeof body.intent === "string" ? body.intent : "script-sdk";
|
|
60
|
+
return {
|
|
61
|
+
method: "GET",
|
|
62
|
+
path: `/api/memory/${encodeURIComponent(memoryId)}?intent=${encodeURIComponent(getIntent)}`,
|
|
63
|
+
};
|
|
60
64
|
}
|
|
61
65
|
case "memory_rate": {
|
|
62
66
|
const event = {
|
|
@@ -50,11 +50,12 @@ declare module "swarm-sdk" {
|
|
|
50
50
|
// --- memory ---
|
|
51
51
|
memory_search(args: {
|
|
52
52
|
query: string;
|
|
53
|
+
intent: string;
|
|
53
54
|
scope?: "all" | "agent" | "swarm";
|
|
54
55
|
limit?: number;
|
|
55
56
|
source?: string;
|
|
56
57
|
}): Promise<unknown>;
|
|
57
|
-
memory_get(args: { memoryId: string }): Promise<unknown>;
|
|
58
|
+
memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
|
|
58
59
|
memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
|
|
59
60
|
// --- tasks ---
|
|
60
61
|
task_list(args?: Record<string, unknown>): Promise<unknown>;
|
|
@@ -32,11 +32,12 @@ declare module "swarm-sdk" {
|
|
|
32
32
|
// --- memory ---
|
|
33
33
|
memory_search(args: {
|
|
34
34
|
query: string;
|
|
35
|
+
intent: string;
|
|
35
36
|
scope?: "all" | "agent" | "swarm";
|
|
36
37
|
limit?: number;
|
|
37
38
|
source?: string;
|
|
38
39
|
}): Promise<unknown>;
|
|
39
|
-
memory_get(args: { memoryId: string }): Promise<unknown>;
|
|
40
|
+
memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
|
|
40
41
|
memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
|
|
41
42
|
// --- tasks ---
|
|
42
43
|
task_list(args?: Record<string, unknown>): Promise<unknown>;
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import pkg from "../package.json";
|
|
3
3
|
import { initDb } from "./be/db";
|
|
4
|
+
import { startPricingRefreshLoop } from "./be/pricing-refresh";
|
|
4
5
|
import { seedPricingFromModelsDev } from "./be/seed-pricing";
|
|
5
6
|
import { registerCancelTaskTool } from "./tools/cancel-task";
|
|
6
7
|
import { registerContextDiffTool } from "./tools/context-diff";
|
|
@@ -172,6 +173,7 @@ export function createServer() {
|
|
|
172
173
|
// call on every boot. See src/be/seed-pricing.ts for the projection logic
|
|
173
174
|
// and the manual-override constants for runtime-fee / ACU pricing.
|
|
174
175
|
seedPricingFromModelsDev();
|
|
176
|
+
startPricingRefreshLoop();
|
|
175
177
|
|
|
176
178
|
const server = new McpServer(
|
|
177
179
|
{
|
package/src/slack/blocks.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import type { AgentTaskStatus, TaskAttachment } from "../types";
|
|
9
9
|
import { buildAgentFsLiveUrl, getAppUrl } from "../utils/constants";
|
|
10
10
|
|
|
11
|
-
// Slack limits section text to 3000 chars; we use 2900 for safety
|
|
12
|
-
const MAX_SECTION_LENGTH = 2900;
|
|
11
|
+
// Slack limits section text to 3000 chars; we use 2900 for safety.
|
|
12
|
+
export const MAX_SECTION_LENGTH = 2900;
|
|
13
|
+
export const MAX_BLOCKS_PER_COMPLETION_MESSAGE = 45;
|
|
13
14
|
|
|
14
15
|
// biome-ignore lint/suspicious/noExplicitAny: Slack block types are complex unions; we build plain objects
|
|
15
16
|
type SlackBlock = any;
|
|
@@ -38,24 +39,33 @@ export function getTaskUrl(taskId: string): string {
|
|
|
38
39
|
* Convert GitHub-flavored markdown to Slack mrkdwn format.
|
|
39
40
|
*
|
|
40
41
|
* Key differences:
|
|
41
|
-
* - GitHub: **bold**, *italic*, ~~strike~~, [text](url)
|
|
42
|
-
* - Slack: *bold*, _italic_, ~strike~,
|
|
42
|
+
* - GitHub: **bold**, __bold__, *italic*, ~~strike~~, ### Header, [text](url)
|
|
43
|
+
* - Slack: *bold*, *bold*, _italic_, ~strike~, *Header*, text (url)
|
|
43
44
|
*/
|
|
44
45
|
export function markdownToSlack(text: string): string {
|
|
45
46
|
return (
|
|
46
47
|
text
|
|
48
|
+
// Images: keep alt text and expose the URL plainly.
|
|
49
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, url) =>
|
|
50
|
+
alt ? `${alt} (${url})` : url,
|
|
51
|
+
)
|
|
52
|
+
// Links: keep a plain URL fallback instead of Slack's <url|text> shortcut.
|
|
53
|
+
// Slack block auto-promotion has historically rejected that shortcut with
|
|
54
|
+
// invalid_blocks, while plain URLs remain copyable and auto-unfurlable.
|
|
55
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, "$1 ($2)")
|
|
47
56
|
// Headers to bold placeholder (# Header -> bold, protected from italic)
|
|
48
57
|
.replace(/^#{1,6}\s+(.+)$/gm, "\uE000$1\uE001")
|
|
49
58
|
// Bold **text** -> placeholder (to avoid italic chain converting *bold* to _italic_)
|
|
50
59
|
.replace(/\*\*(.+?)\*\*/g, "\uE000$1\uE001")
|
|
60
|
+
// Bold __text__ -> placeholder
|
|
61
|
+
.replace(/__(.+?)__/g, "\uE000$1\uE001")
|
|
51
62
|
// Italic *text* -> _text_ (single asterisks, now safe from bold placeholders)
|
|
52
63
|
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_")
|
|
64
|
+
// Italic _text_ already matches Slack mrkdwn; leave it alone.
|
|
53
65
|
// Restore bold from placeholder -> *text*
|
|
54
66
|
.replace(/\uE000(.+?)\uE001/g, "*$1*")
|
|
55
67
|
// Strikethrough ~~text~~ -> ~text~
|
|
56
68
|
.replace(/~~(.+?)~~/g, "~$1~")
|
|
57
|
-
// Links [text](url) -> <url|text>
|
|
58
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
|
|
59
69
|
// Inline code already works the same
|
|
60
70
|
// Bullet points already work the same
|
|
61
71
|
// Remove excessive blank lines
|
|
@@ -66,7 +76,7 @@ export function markdownToSlack(text: string): string {
|
|
|
66
76
|
/**
|
|
67
77
|
* Split text into chunks that fit within Slack's section text limit.
|
|
68
78
|
*/
|
|
69
|
-
function
|
|
79
|
+
export function splitSlackSectionText(text: string): string[] {
|
|
70
80
|
if (text.length <= MAX_SECTION_LENGTH) return [text];
|
|
71
81
|
|
|
72
82
|
const chunks: string[] = [];
|
|
@@ -248,17 +258,50 @@ export function buildCompletedBlocks(opts: {
|
|
|
248
258
|
|
|
249
259
|
// Only include body if not minimal (agent didn't reply via slack-reply)
|
|
250
260
|
if (!opts.minimal) {
|
|
251
|
-
for (const chunk of
|
|
261
|
+
for (const chunk of splitSlackSectionText(opts.body)) {
|
|
252
262
|
blocks.push(sectionBlock(chunk));
|
|
253
263
|
}
|
|
254
264
|
} else if (opts.trailer && opts.trailer.length > 0) {
|
|
255
|
-
for (const chunk of
|
|
265
|
+
for (const chunk of splitSlackSectionText(opts.trailer)) {
|
|
256
266
|
blocks.push(sectionBlock(chunk));
|
|
257
267
|
}
|
|
258
268
|
}
|
|
259
269
|
return blocks;
|
|
260
270
|
}
|
|
261
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Build one or more completed-task block payloads. The first payload carries
|
|
274
|
+
* the normal completion header; continuation payloads carry a compact part
|
|
275
|
+
* header. This keeps long completion summaries inside Slack's block limits
|
|
276
|
+
* without dropping body text.
|
|
277
|
+
*/
|
|
278
|
+
export function buildCompletedBlockBatches(opts: Parameters<typeof buildCompletedBlocks>[0]) {
|
|
279
|
+
const allBlocks = buildCompletedBlocks(opts);
|
|
280
|
+
if (allBlocks.length <= MAX_BLOCKS_PER_COMPLETION_MESSAGE) return [allBlocks];
|
|
281
|
+
|
|
282
|
+
const header = allBlocks[0];
|
|
283
|
+
const bodyBlocks = allBlocks.slice(1);
|
|
284
|
+
const bodyLimit = MAX_BLOCKS_PER_COMPLETION_MESSAGE - 1;
|
|
285
|
+
const batches: SlackBlock[][] = [];
|
|
286
|
+
|
|
287
|
+
for (let start = 0; start < bodyBlocks.length; start += bodyLimit) {
|
|
288
|
+
const partBlocks = bodyBlocks.slice(start, start + bodyLimit);
|
|
289
|
+
if (start === 0) {
|
|
290
|
+
batches.push([header, ...partBlocks]);
|
|
291
|
+
} else {
|
|
292
|
+
const part = batches.length + 1;
|
|
293
|
+
batches.push([
|
|
294
|
+
sectionBlock(
|
|
295
|
+
`↳ *${opts.agentName}* (${getTaskLink(opts.taskId)}) continued · part ${part}`,
|
|
296
|
+
),
|
|
297
|
+
...partBlocks,
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return batches;
|
|
303
|
+
}
|
|
304
|
+
|
|
262
305
|
/**
|
|
263
306
|
* Build blocks for a failed task response.
|
|
264
307
|
* Single-line header, then error in code block.
|
|
@@ -369,7 +412,10 @@ function truncateOutput(text: string): string {
|
|
|
369
412
|
const sentenceEnd = text.search(/\.\s/);
|
|
370
413
|
const firstSentence = sentenceEnd !== -1 ? text.slice(0, sentenceEnd + 1) : text;
|
|
371
414
|
if (firstSentence.length <= MAX_OUTPUT_LENGTH) return firstSentence;
|
|
372
|
-
|
|
415
|
+
const boundary = text.lastIndexOf(" ", MAX_OUTPUT_LENGTH);
|
|
416
|
+
const cut = boundary >= MAX_OUTPUT_LENGTH / 2 ? boundary : MAX_OUTPUT_LENGTH;
|
|
417
|
+
const omitted = text.length - cut;
|
|
418
|
+
return `${text.slice(0, cut).trimEnd()}… (${omitted} more chars; full output in thread)`;
|
|
373
419
|
}
|
|
374
420
|
|
|
375
421
|
/**
|
|
@@ -399,7 +445,7 @@ function renderChildDetail(node: TreeNode, indent: string): string[] {
|
|
|
399
445
|
}
|
|
400
446
|
|
|
401
447
|
if (node.status === "completed" && !node.slackReplySent && node.output) {
|
|
402
|
-
lines.push(`${indent}${truncateOutput(node.output)}`);
|
|
448
|
+
lines.push(`${indent}${truncateOutput(markdownToSlack(node.output))}`);
|
|
403
449
|
}
|
|
404
450
|
|
|
405
451
|
return lines;
|
|
@@ -423,7 +469,7 @@ function renderTree(root: TreeNode): string {
|
|
|
423
469
|
lines.push(` Error: ${root.failureReason}`);
|
|
424
470
|
}
|
|
425
471
|
if (root.status === "completed" && !root.slackReplySent && root.output) {
|
|
426
|
-
lines.push(` ${truncateOutput(root.output)}`);
|
|
472
|
+
lines.push(` ${truncateOutput(markdownToSlack(root.output))}`);
|
|
427
473
|
}
|
|
428
474
|
return lines.join("\n");
|
|
429
475
|
}
|
package/src/slack/responses.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Agent, AgentTask } from "../types";
|
|
|
4
4
|
import { getSlackApp } from "./app";
|
|
5
5
|
import {
|
|
6
6
|
buildCancelledBlocks,
|
|
7
|
+
buildCompletedBlockBatches,
|
|
7
8
|
buildCompletedBlocks,
|
|
8
9
|
buildFailedBlocks,
|
|
9
10
|
buildProgressBlocks,
|
|
@@ -74,7 +75,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
|
74
75
|
console.log(
|
|
75
76
|
`[Slack] sendTaskResponse: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
|
|
76
77
|
);
|
|
77
|
-
const
|
|
78
|
+
const completionOpts = {
|
|
78
79
|
agentName,
|
|
79
80
|
taskId: task.id,
|
|
80
81
|
body,
|
|
@@ -84,15 +85,21 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
|
84
85
|
// trailing addendum so links are visible without expanding the card.
|
|
85
86
|
minimal: !!task.slackReplySent,
|
|
86
87
|
trailer: task.slackReplySent ? attachmentsBlock : undefined,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
};
|
|
89
|
+
const blockBatches = buildCompletedBlockBatches(completionOpts);
|
|
90
|
+
for (let i = 0; i < blockBatches.length; i++) {
|
|
91
|
+
await sendWithPersona(client, {
|
|
92
|
+
channel: task.slackChannelId,
|
|
93
|
+
thread_ts: task.slackThreadTs,
|
|
94
|
+
text:
|
|
95
|
+
task.slackReplySent || i > 0
|
|
96
|
+
? `✅ ${agentName} completed${i > 0 ? ` (continued ${i + 1}/${blockBatches.length})` : ""}`
|
|
97
|
+
: body,
|
|
98
|
+
username: getAgentDisplayName(agent),
|
|
99
|
+
icon_emoji: getAgentEmoji(agent),
|
|
100
|
+
blocks: blockBatches[i],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
96
103
|
} else if (task.status === "failed") {
|
|
97
104
|
const reason = task.failureReason || "Unknown error";
|
|
98
105
|
const blocks = buildFailedBlocks({ agentName, taskId: task.id, reason });
|
|
@@ -199,6 +206,7 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
199
206
|
const agentName = agent.name;
|
|
200
207
|
let blocks: unknown[];
|
|
201
208
|
let text: string;
|
|
209
|
+
let completionBlockBatches: unknown[][] | undefined;
|
|
202
210
|
|
|
203
211
|
if (task.status === "completed") {
|
|
204
212
|
const output = task.output || "Task completed.";
|
|
@@ -212,14 +220,16 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
212
220
|
console.log(
|
|
213
221
|
`[Slack] updateToFinal: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
|
|
214
222
|
);
|
|
215
|
-
|
|
223
|
+
const completionOpts = {
|
|
216
224
|
agentName,
|
|
217
225
|
taskId: task.id,
|
|
218
226
|
body,
|
|
219
227
|
duration,
|
|
220
228
|
minimal: !!task.slackReplySent,
|
|
221
229
|
trailer: task.slackReplySent ? attachmentsBlock : undefined,
|
|
222
|
-
}
|
|
230
|
+
};
|
|
231
|
+
completionBlockBatches = buildCompletedBlockBatches(completionOpts);
|
|
232
|
+
blocks = completionBlockBatches[0] ?? buildCompletedBlocks(completionOpts);
|
|
223
233
|
text = task.slackReplySent ? `✅ ${agentName} completed` : body;
|
|
224
234
|
} else if (task.status === "cancelled") {
|
|
225
235
|
blocks = buildCancelledBlocks({ agentName, taskId: task.id });
|
|
@@ -238,6 +248,19 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
238
248
|
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
239
249
|
blocks: blocks as any,
|
|
240
250
|
});
|
|
251
|
+
|
|
252
|
+
if (completionBlockBatches) {
|
|
253
|
+
for (let i = 1; i < completionBlockBatches.length; i++) {
|
|
254
|
+
await sendWithPersona(app.client, {
|
|
255
|
+
channel: task.slackChannelId,
|
|
256
|
+
thread_ts: task.slackThreadTs ?? messageTs,
|
|
257
|
+
text: `✅ ${agentName} completed (continued ${i + 1}/${completionBlockBatches.length})`,
|
|
258
|
+
username: getAgentDisplayName(agent),
|
|
259
|
+
icon_emoji: getAgentEmoji(agent),
|
|
260
|
+
blocks: completionBlockBatches[i],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
241
264
|
return true;
|
|
242
265
|
} catch (error) {
|
|
243
266
|
console.error(`[Slack] Failed to update task message to final state:`, error);
|
package/src/slack/watcher.ts
CHANGED
|
@@ -696,13 +696,6 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
696
696
|
}
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
-
// Skip tasks tracked in a tree — they're rendered by processTreeMessages()
|
|
700
|
-
// But mark as notified to prevent re-processing if tree is cleaned up
|
|
701
|
-
if (taskToTree.has(task.id)) {
|
|
702
|
-
notifiedCompletions.set(task.id, now);
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
699
|
const completionKey = `completion:${task.id}`;
|
|
707
700
|
|
|
708
701
|
// Skip if already notified or currently sending or sent recently
|
|
@@ -710,6 +703,34 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
710
703
|
const lastSent = lastSendTime.get(completionKey);
|
|
711
704
|
if (lastSent && now - lastSent < MIN_SEND_INTERVAL) continue;
|
|
712
705
|
|
|
706
|
+
// Tasks tracked in a tree are still rendered by processTreeMessages().
|
|
707
|
+
// Successful tasks without their own slack-reply also get a full
|
|
708
|
+
// threaded completion message so the tree's compact preview is not the
|
|
709
|
+
// only place their output appears.
|
|
710
|
+
if (taskToTree.has(task.id)) {
|
|
711
|
+
if (task.status !== "completed" || task.slackReplySent) {
|
|
712
|
+
notifiedCompletions.set(task.id, now);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
pendingSends.add(completionKey);
|
|
717
|
+
notifiedCompletions.set(task.id, now);
|
|
718
|
+
lastSendTime.set(completionKey, now);
|
|
719
|
+
try {
|
|
720
|
+
await sendTaskResponse(task);
|
|
721
|
+
console.log(
|
|
722
|
+
`[Slack] Sent full tree-tracked completion for task ${task.id.slice(0, 8)}`,
|
|
723
|
+
);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
notifiedCompletions.delete(task.id);
|
|
726
|
+
lastSendTime.delete(completionKey);
|
|
727
|
+
console.error(`[Slack] Failed to send tree-tracked completion:`, error);
|
|
728
|
+
} finally {
|
|
729
|
+
pendingSends.delete(completionKey);
|
|
730
|
+
}
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
|
|
713
734
|
// Mark as pending and notified BEFORE sending
|
|
714
735
|
pendingSends.add(completionKey);
|
|
715
736
|
notifiedCompletions.set(task.id, now);
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
completeStructured,
|
|
6
|
+
defaultSpawnClaudeCli,
|
|
7
|
+
} from "../../utils/internal-ai/complete-structured.js";
|
|
5
8
|
import type { ResolvedCredential } from "../../utils/internal-ai/credentials.js";
|
|
6
9
|
|
|
7
10
|
const ResultZodSchema = z.object({
|
|
@@ -274,3 +277,33 @@ describe("completeStructured", () => {
|
|
|
274
277
|
expect(match).toBeDefined();
|
|
275
278
|
});
|
|
276
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("defaultSpawnClaudeCli", () => {
|
|
282
|
+
test("sets SKIP_SESSION_SUMMARY=1 in the child env (Stop-hook recursion guard)", async () => {
|
|
283
|
+
// Without this guard, the spawned `claude -p` summarizer session fires the
|
|
284
|
+
// same global Stop hook on exit, which spawns another summarizer claude,
|
|
285
|
+
// recursively — observed OOM-wedging 8GB E2B worker sandboxes.
|
|
286
|
+
const fakeBinary = `/tmp/fake-claude-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`;
|
|
287
|
+
await Bun.write(
|
|
288
|
+
fakeBinary,
|
|
289
|
+
'#!/usr/bin/env bash\ncat >/dev/null\nprintf \'{"result":"SKIP_SESSION_SUMMARY=%s"}\' "$SKIP_SESSION_SUMMARY"\n',
|
|
290
|
+
);
|
|
291
|
+
const savedBinary = process.env.CLAUDE_BINARY;
|
|
292
|
+
const savedSkip = process.env.SKIP_SESSION_SUMMARY;
|
|
293
|
+
const savedBridge = process.env.SWARM_USE_CLAUDE_BRIDGE;
|
|
294
|
+
process.env.CLAUDE_BINARY = `bash ${fakeBinary}`;
|
|
295
|
+
// Ensure a false-pass via inheritance is impossible.
|
|
296
|
+
delete process.env.SKIP_SESSION_SUMMARY;
|
|
297
|
+
delete process.env.SWARM_USE_CLAUDE_BRIDGE;
|
|
298
|
+
try {
|
|
299
|
+
const out = await defaultSpawnClaudeCli("prompt", "haiku");
|
|
300
|
+
expect(out).toBe("SKIP_SESSION_SUMMARY=1");
|
|
301
|
+
} finally {
|
|
302
|
+
if (savedBinary === undefined) delete process.env.CLAUDE_BINARY;
|
|
303
|
+
else process.env.CLAUDE_BINARY = savedBinary;
|
|
304
|
+
if (savedSkip !== undefined) process.env.SKIP_SESSION_SUMMARY = savedSkip;
|
|
305
|
+
if (savedBridge !== undefined) process.env.SWARM_USE_CLAUDE_BRIDGE = savedBridge;
|
|
306
|
+
await Bun.$`rm -f ${fakeBinary}`.quiet();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|