@desplega.ai/agent-swarm 1.84.1 → 1.85.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.
@@ -13,6 +13,7 @@ import {
13
13
  initDb,
14
14
  startTask,
15
15
  } from "../be/db";
16
+ import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
16
17
 
17
18
  const TEST_DB_PATH = "./test-task-completion-idempotency.sqlite";
18
19
 
@@ -318,3 +319,91 @@ describe("store-progress idempotency on terminal status (integration via DB laye
318
319
  expect(after!.output).toBe("manually written");
319
320
  });
320
321
  });
322
+
323
+ interface FollowUpRow {
324
+ id: string;
325
+ agentId: string | null;
326
+ parentTaskId: string | null;
327
+ taskType: string | null;
328
+ task: string;
329
+ slackChannelId: string | null;
330
+ slackThreadTs: string | null;
331
+ slackUserId: string | null;
332
+ }
333
+
334
+ function listFollowUpTasks(parentTaskId: string): FollowUpRow[] {
335
+ return getDb()
336
+ .prepare<FollowUpRow, [string]>(
337
+ `SELECT id, agentId, parentTaskId, taskType, task, slackChannelId, slackThreadTs, slackUserId
338
+ FROM agent_tasks
339
+ WHERE parentTaskId = ? AND taskType = 'follow-up'
340
+ ORDER BY createdAt ASC`,
341
+ )
342
+ .all(parentTaskId);
343
+ }
344
+
345
+ describe("worker task follow-up creation", () => {
346
+ test("creates lead follow-up for completed worker task", () => {
347
+ const lead = createAgent({
348
+ name: "follow-up-lead-1",
349
+ isLead: true,
350
+ status: "idle",
351
+ capabilities: [],
352
+ });
353
+ const worker = createAgent({
354
+ name: "follow-up-worker-1",
355
+ isLead: false,
356
+ status: "idle",
357
+ capabilities: [],
358
+ });
359
+ const task = createTaskExtended("Worker task", {
360
+ agentId: worker.id,
361
+ slackChannelId: "C123",
362
+ slackThreadTs: "1700000000.000001",
363
+ slackUserId: "U123",
364
+ });
365
+ startTask(task.id, worker.id);
366
+
367
+ const completed = completeTask(task.id, "Worker output");
368
+ expect(completed).not.toBeNull();
369
+
370
+ const followUp = createWorkerTaskFollowUp({
371
+ task: completed!,
372
+ status: "completed",
373
+ output: "Worker output",
374
+ });
375
+
376
+ expect(followUp).not.toBeNull();
377
+ const rows = listFollowUpTasks(task.id);
378
+ expect(rows).toHaveLength(1);
379
+ expect(rows[0]!.agentId).toBe(lead.id);
380
+ expect(rows[0]!.parentTaskId).toBe(task.id);
381
+ expect(rows[0]!.slackChannelId).toBe("C123");
382
+ expect(rows[0]!.slackThreadTs).toBe("1700000000.000001");
383
+ expect(rows[0]!.slackUserId).toBe("U123");
384
+ expect(rows[0]!.task).toContain("Worker output");
385
+ });
386
+
387
+ test("does not create follow-up for lead-owned task", () => {
388
+ const lead = createAgent({
389
+ name: "follow-up-lead-2",
390
+ isLead: true,
391
+ status: "idle",
392
+ capabilities: [],
393
+ });
394
+ const task = createTaskExtended("Lead task", { agentId: lead.id });
395
+ startTask(task.id, lead.id);
396
+
397
+ const completed = completeTask(task.id, "Lead output");
398
+ expect(completed).not.toBeNull();
399
+
400
+ const followUp = createWorkerTaskFollowUp({
401
+ task: completed!,
402
+ status: "completed",
403
+ output: "Lead output",
404
+ });
405
+
406
+ expect(followUp).toBeNull();
407
+ expect(listFollowUpTasks(task.id)).toHaveLength(0);
408
+ });
409
+ });
@@ -0,0 +1,118 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getOAuthTokens } from "@/be/db-queries/oauth";
4
+ import { ensureTokenOrThrow } from "@/oauth/ensure-token";
5
+ import { createToolRegistrar } from "@/tools/utils";
6
+ import { registerVolatileSecret } from "@/utils/secret-scrubber";
7
+
8
+ type OAuthProvider = string;
9
+
10
+ export interface OAuthAccessTokenResult {
11
+ provider: OAuthProvider;
12
+ accessToken: string;
13
+ expiresAt: string;
14
+ tokenType: "Bearer";
15
+ }
16
+
17
+ function assertTokenUsable(
18
+ provider: OAuthProvider,
19
+ expiresAt: string,
20
+ minValidityMs: number,
21
+ ): void {
22
+ const expiresAtMs = Date.parse(expiresAt);
23
+ if (!Number.isFinite(expiresAtMs)) {
24
+ throw new Error(`${provider} OAuth token has an invalid expiry`);
25
+ }
26
+ if (expiresAtMs - Date.now() < minValidityMs) {
27
+ throw new Error(
28
+ `${provider} OAuth token is expired or expiring soon and could not be refreshed`,
29
+ );
30
+ }
31
+ }
32
+
33
+ export async function resolveOAuthAccessToken(
34
+ provider: OAuthProvider,
35
+ minValiditySeconds = 300,
36
+ ): Promise<OAuthAccessTokenResult> {
37
+ const minValidityMs = minValiditySeconds * 1000;
38
+ await ensureTokenOrThrow(provider, minValidityMs);
39
+
40
+ const tokens = getOAuthTokens(provider);
41
+ if (!tokens) {
42
+ throw new Error(`${provider} OAuth tokens are not connected`);
43
+ }
44
+
45
+ assertTokenUsable(provider, tokens.expiresAt, minValidityMs);
46
+ registerVolatileSecret(tokens.accessToken, `${provider.toUpperCase()}_OAUTH_ACCESS_TOKEN`);
47
+
48
+ return {
49
+ provider,
50
+ accessToken: tokens.accessToken,
51
+ expiresAt: tokens.expiresAt,
52
+ tokenType: "Bearer",
53
+ };
54
+ }
55
+
56
+ export const registerGetOauthAccessTokenTool = (server: McpServer) => {
57
+ createToolRegistrar(server)(
58
+ "get-oauth-access-token",
59
+ {
60
+ title: "Get OAuth access token",
61
+ description:
62
+ "Return a valid plaintext OAuth access token for an integrated tracker. The token is refreshed first when it is near expiry. Returns access_token only; never returns refresh_token.",
63
+ annotations: { destructiveHint: false, openWorldHint: true },
64
+ inputSchema: z.object({
65
+ provider: z
66
+ .string()
67
+ .min(1)
68
+ .max(64)
69
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, "provider must be a slug")
70
+ .describe("OAuth provider slug to read from oauth_tokens (for example: linear, jira)."),
71
+ minValiditySeconds: z
72
+ .number()
73
+ .int()
74
+ .min(0)
75
+ .max(3600)
76
+ .optional()
77
+ .default(300)
78
+ .describe("Minimum remaining token lifetime required before returning it."),
79
+ }),
80
+ outputSchema: z.object({
81
+ success: z.boolean(),
82
+ message: z.string(),
83
+ provider: z.string().optional(),
84
+ accessToken: z.string().optional(),
85
+ expiresAt: z.string().optional(),
86
+ tokenType: z.literal("Bearer").optional(),
87
+ }),
88
+ },
89
+ async ({ provider, minValiditySeconds }, _requestInfo, _meta) => {
90
+ try {
91
+ const token = await resolveOAuthAccessToken(provider, minValiditySeconds);
92
+ const message = `${provider} OAuth access token resolved; expires at ${token.expiresAt}.`;
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: `${message}\n\n${token.accessToken}`,
98
+ },
99
+ ],
100
+ structuredContent: {
101
+ success: true,
102
+ message,
103
+ ...token,
104
+ },
105
+ };
106
+ } catch (err) {
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ return {
109
+ content: [{ type: "text", text: `Failed to resolve OAuth access token: ${message}` }],
110
+ structuredContent: {
111
+ success: false,
112
+ message,
113
+ },
114
+ };
115
+ }
116
+ },
117
+ );
118
+ };
@@ -3,14 +3,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import * as z from "zod";
4
4
  import {
5
5
  completeTask,
6
- createTaskExtended,
7
6
  failTask,
8
7
  getAgentById,
9
8
  getDb,
10
- getLeadAgent,
11
9
  getResolvedConfig,
12
10
  getSessionLogsByTaskId,
13
- getTaskAttachments,
14
11
  getTaskById,
15
12
  insertTaskAttachment,
16
13
  updateAgentStatusFromCapacity,
@@ -19,11 +16,9 @@ import {
19
16
  import { getEmbeddingProvider, getMemoryStore } from "@/be/memory";
20
17
  import { getRetrievalsForTask } from "@/be/memory/raters/retrieval";
21
18
  import { runServerRaters } from "@/be/memory/raters/run-server-raters";
22
- import { resolveTemplate } from "@/prompts/resolver";
19
+ import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
23
20
  import { createToolRegistrar } from "@/tools/utils";
24
- import { AgentTaskSchema, AttachmentInputSchema, type TaskAttachment } from "@/types";
25
- // Side-effect import: registers task lifecycle templates in the in-memory registry
26
- import "./templates";
21
+ import { AgentTaskSchema, AttachmentInputSchema } from "@/types";
27
22
  import { validateJsonSchema } from "@/workflows/json-schema-validator";
28
23
 
29
24
  // Phase 11: the `cost` / `costData` field was removed from this tool's input
@@ -33,29 +28,6 @@ import { validateJsonSchema } from "@/workflows/json-schema-validator";
33
28
  // echoed the schema example, producing noise rows keyed `mcp-<taskId>-<ts>`
34
29
  // that double-counted alongside the harness's authoritative entry.
35
30
 
36
- function attachmentPointer(a: TaskAttachment): string {
37
- switch (a.kind) {
38
- case "url":
39
- return a.url ?? "";
40
- case "page":
41
- return `page:${a.pageId ?? ""}`;
42
- case "agent-fs":
43
- return `agent-fs:${a.path ?? ""}`;
44
- case "shared-fs":
45
- return `shared-fs:${a.path ?? ""}`;
46
- }
47
- }
48
-
49
- function formatAttachmentsBlock(attachments: TaskAttachment[]): string {
50
- if (attachments.length === 0) return "";
51
- const lines = attachments.map((a) => {
52
- const tag = a.isPrimary ? "[primary] " : "";
53
- const intent = a.intent ? ` (intent: ${a.intent})` : "";
54
- return `- ${tag}${a.name} — ${attachmentPointer(a)}${intent}`;
55
- });
56
- return `\n\nAttachments (${attachments.length}):\n${lines.join("\n")}`;
57
- }
58
-
59
31
  export const registerStoreProgressTool = (server: McpServer) => {
60
32
  createToolRegistrar(server)(
61
33
  "store-progress",
@@ -460,53 +432,16 @@ export const registerStoreProgressTool = (server: McpServer) => {
460
432
  !("wasNoOp" in result && result.wasNoOp)
461
433
  ) {
462
434
  try {
463
- const taskAgent = getAgentById(result.task.agentId ?? "");
464
- // Only create follow-ups for worker tasks (not lead's own tasks)
465
- if (taskAgent && !taskAgent.isLead) {
466
- const leadAgent = getLeadAgent();
467
- if (leadAgent) {
468
- const agentName = taskAgent.name || result.task.agentId?.slice(0, 8) || "Unknown";
469
- const taskDesc = result.task.task.slice(0, 200);
470
-
471
- let followUpDescription: string;
472
- if (status === "completed") {
473
- const attachmentsBlock = formatAttachmentsBlock(getTaskAttachments(taskId));
474
- const outputSummary = output
475
- ? `${output.slice(0, 500)}${output.length > 500 ? "..." : ""}${attachmentsBlock}`
476
- : `(no output)${attachmentsBlock}`;
477
- const completedResult = resolveTemplate("task.worker.completed", {
478
- agent_name: agentName,
479
- task_desc: taskDesc,
480
- output_summary: outputSummary,
481
- task_id: taskId,
482
- });
483
- followUpDescription = completedResult.text;
484
- } else {
485
- const reason = failureReason || "(no reason given)";
486
- const failedResult = resolveTemplate("task.worker.failed", {
487
- agent_name: agentName,
488
- task_desc: taskDesc,
489
- failure_reason: reason,
490
- task_id: taskId,
491
- });
492
- followUpDescription = failedResult.text;
493
- }
494
-
495
- // If the original task came from Slack, forward context so lead can reply
496
- createTaskExtended(followUpDescription, {
497
- agentId: leadAgent.id,
498
- source: "system",
499
- taskType: "follow-up",
500
- parentTaskId: taskId,
501
- slackChannelId: result.task.slackChannelId,
502
- slackThreadTs: result.task.slackThreadTs,
503
- slackUserId: result.task.slackUserId,
504
- });
505
-
506
- console.log(
507
- `[store-progress] Created follow-up task for lead (${leadAgent.name}) — ${status} task ${taskId.slice(0, 8)} by ${agentName}`,
508
- );
509
- }
435
+ const followUp = createWorkerTaskFollowUp({
436
+ task: result.task,
437
+ status,
438
+ output,
439
+ failureReason,
440
+ });
441
+ if (followUp) {
442
+ console.log(
443
+ `[store-progress] Created follow-up task ${followUp.id.slice(0, 8)} for ${status} task ${taskId.slice(0, 8)}`,
444
+ );
510
445
  }
511
446
  } catch (err) {
512
447
  // Non-blocking — follow-up task creation failure should not affect the store-progress response
@@ -104,8 +104,9 @@ export const DEFERRED_TOOLS = new Set([
104
104
  "send-whatsapp-message",
105
105
  "reply-whatsapp-message",
106
106
 
107
- // Tracker (6)
107
+ // Tracker (7)
108
108
  "tracker-status",
109
+ "get-oauth-access-token",
109
110
  "tracker-link-task",
110
111
  "tracker-unlink",
111
112
  "tracker-sync-status",
package/src/types.ts CHANGED
@@ -212,6 +212,10 @@ export const AgentTaskSchema = z.object({
212
212
  // Provider tracking — which harness provider ran this task
213
213
  provider: ProviderNameSchema.optional(),
214
214
  providerMeta: z.record(z.string(), z.unknown()).optional(),
215
+
216
+ // Aggregated session cost for task list/read models. Undefined means no
217
+ // session cost rows have been recorded for this task.
218
+ totalCostUsd: z.number().min(0).optional(),
215
219
  });
216
220
 
217
221
  // ============================================================================
@@ -1328,6 +1332,7 @@ export type AgentTaskSummary = Pick<
1328
1332
  | "lastUpdatedAt"
1329
1333
  | "finishedAt"
1330
1334
  | "peakContextPercent"
1335
+ | "totalCostUsd"
1331
1336
  >;
1332
1337
 
1333
1338
  export const PageVersionSchema = z.object({
@@ -142,6 +142,7 @@ interface ScrubCache {
142
142
  }
143
143
 
144
144
  let cache: ScrubCache | null = null;
145
+ const volatileSecrets = new Map<string, string>();
145
146
 
146
147
  /** Fingerprint current env so we can invalidate cache cheaply when it changes. */
147
148
  function snapshotEnv(): string {
@@ -225,6 +226,12 @@ export function scrubSecrets(text: string | null | undefined): string {
225
226
  }
226
227
  }
227
228
 
229
+ for (const [value, name] of volatileSecrets) {
230
+ if (out.includes(value)) {
231
+ out = out.split(value).join(`[REDACTED:${name}]`);
232
+ }
233
+ }
234
+
228
235
  // Pass 2: structural patterns (catches secrets we never saw in env, e.g.
229
236
  // a token pasted into a tool_result by the operator or fetched from a
230
237
  // third-party API during a task).
@@ -265,3 +272,19 @@ export function scrubObject<T>(value: T, seen = new WeakSet<object>()): T {
265
272
  export function refreshSecretScrubberCache(): void {
266
273
  cache = null;
267
274
  }
275
+
276
+ /**
277
+ * Register a runtime-fetched secret that is not present in process.env.
278
+ *
279
+ * Use this before returning short-lived tokens through an API/tool result so
280
+ * follow-on logs, telemetry previews, and session-log egress can redact the
281
+ * concrete value even though the caller still receives it.
282
+ */
283
+ export function registerVolatileSecret(value: string, name: string): void {
284
+ if (value.length < MIN_VALUE_LENGTH) return;
285
+ volatileSecrets.set(value, name);
286
+ }
287
+
288
+ export function clearVolatileSecretsForTesting(): void {
289
+ volatileSecrets.clear();
290
+ }