@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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.84.1",
5
+ "version": "1.85.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.84.1",
3
+ "version": "1.85.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -180,3 +180,36 @@ export function isTokenExpiringSoon(provider: string, bufferMs = 5 * 60 * 1000):
180
180
  const expiresAt = new Date(tokens.expiresAt).getTime();
181
181
  return expiresAt - Date.now() < bufferMs;
182
182
  }
183
+
184
+ // ── OAuth Refresh Locks ──
185
+
186
+ export function acquireOAuthRefreshLock(provider: string, ttlMs: number): string | null {
187
+ const owner = crypto.randomUUID();
188
+ const now = Date.now();
189
+ const expiresAt = new Date(now + ttlMs).toISOString();
190
+ const nowIso = new Date(now).toISOString();
191
+
192
+ getDb()
193
+ .query(
194
+ `INSERT INTO oauth_refresh_locks (provider, owner, expiresAt, createdAt, updatedAt)
195
+ VALUES (?, ?, ?, ?, ?)
196
+ ON CONFLICT(provider) DO UPDATE SET
197
+ owner = excluded.owner,
198
+ expiresAt = excluded.expiresAt,
199
+ updatedAt = excluded.updatedAt
200
+ WHERE oauth_refresh_locks.expiresAt <= ?`,
201
+ )
202
+ .run(provider, owner, expiresAt, nowIso, nowIso, nowIso);
203
+
204
+ const row = getDb()
205
+ .query("SELECT owner FROM oauth_refresh_locks WHERE provider = ?")
206
+ .get(provider) as { owner: string } | null;
207
+
208
+ return row?.owner === owner ? owner : null;
209
+ }
210
+
211
+ export function releaseOAuthRefreshLock(provider: string, owner: string): void {
212
+ getDb()
213
+ .query("DELETE FROM oauth_refresh_locks WHERE provider = ? AND owner = ?")
214
+ .run(provider, owner);
215
+ }
package/src/be/db.ts CHANGED
@@ -1012,6 +1012,7 @@ type AgentTaskRow = {
1012
1012
  swarmVersion: string | null;
1013
1013
  provider: string | null;
1014
1014
  providerMeta: string | null;
1015
+ totalCostUsd?: number | null;
1015
1016
  };
1016
1017
 
1017
1018
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
@@ -1075,6 +1076,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1075
1076
  swarmVersion: row.swarmVersion ?? undefined,
1076
1077
  provider: (row.provider as ProviderName | null) ?? undefined,
1077
1078
  providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
1079
+ totalCostUsd: row.totalCostUsd ?? undefined,
1078
1080
  };
1079
1081
  }
1080
1082
 
@@ -1110,6 +1112,7 @@ function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
1110
1112
  lastUpdatedAt: t.lastUpdatedAt,
1111
1113
  finishedAt: t.finishedAt,
1112
1114
  peakContextPercent: t.peakContextPercent,
1115
+ totalCostUsd: t.totalCostUsd,
1113
1116
  };
1114
1117
  }
1115
1118
 
@@ -1504,7 +1507,10 @@ export function getAllTasks(
1504
1507
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1505
1508
  const limit = filters?.limit ?? 25;
1506
1509
  const offset = filters?.offset ?? 0;
1507
- const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1510
+ const query = `SELECT agent_tasks.*,
1511
+ (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
1512
+ FROM agent_tasks ${whereClause}
1513
+ ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1508
1514
 
1509
1515
  const rows = getDb()
1510
1516
  .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
@@ -0,0 +1,8 @@
1
+ -- Cross-process mutex for OAuth refresh-token rotation.
2
+ CREATE TABLE IF NOT EXISTS oauth_refresh_locks (
3
+ provider TEXT PRIMARY KEY,
4
+ owner TEXT NOT NULL,
5
+ expiresAt TEXT NOT NULL,
6
+ createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
7
+ updatedAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8
+ );
@@ -48,6 +48,7 @@ import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
48
48
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
49
49
  import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
50
50
  import { detectVcsProvider } from "../vcs/index.ts";
51
+ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
51
52
  import { interpolate } from "../workflows/template.ts";
52
53
  import { buildContextPreamble } from "./context-preamble.ts";
53
54
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
@@ -704,6 +705,56 @@ Extract the structured data from the progress updates above. Return ONLY valid J
704
705
  }
705
706
  }
706
707
 
708
+ async function validateProviderOutputIfNeeded(
709
+ config: ApiConfig,
710
+ taskId: string,
711
+ providerOutput: string,
712
+ ): Promise<{ ok: true } | { ok: false; failReason: string }> {
713
+ const headers: Record<string, string> = {
714
+ "Content-Type": "application/json",
715
+ };
716
+ if (config.apiKey) {
717
+ headers.Authorization = `Bearer ${config.apiKey}`;
718
+ }
719
+
720
+ try {
721
+ const taskRes = await fetch(`${config.apiUrl}/api/tasks/${taskId}`, { headers });
722
+ if (!taskRes.ok) {
723
+ return { ok: true };
724
+ }
725
+
726
+ const taskData = (await taskRes.json()) as {
727
+ outputSchema?: Record<string, unknown>;
728
+ };
729
+ if (!taskData.outputSchema || typeof taskData.outputSchema !== "object") {
730
+ return { ok: true };
731
+ }
732
+
733
+ let parsed: unknown;
734
+ try {
735
+ parsed = JSON.parse(providerOutput);
736
+ } catch {
737
+ return {
738
+ ok: false,
739
+ failReason:
740
+ "Structured output required by outputSchema but provider output was not valid JSON",
741
+ };
742
+ }
743
+
744
+ const validationErrors = validateJsonSchema(taskData.outputSchema, parsed);
745
+ if (validationErrors.length > 0) {
746
+ return {
747
+ ok: false,
748
+ failReason: `Structured output did not match outputSchema: ${validationErrors.join("; ")}`,
749
+ };
750
+ }
751
+ } catch {
752
+ return { ok: true };
753
+ }
754
+
755
+ return { ok: true };
756
+ }
757
+
707
758
  export async function ensureTaskFinished(
708
759
  config: ApiConfig,
709
760
  role: string,
@@ -734,12 +785,14 @@ export async function ensureTaskFinished(
734
785
  if (status === "failed") {
735
786
  body.failureReason = failureReason || `Claude process exited with code ${exitCode}`;
736
787
  } else if (providerOutput) {
737
- // Provider already supplied structured output (e.g. Devin) — use directly.
738
- // NOTE: providerOutput is NOT validated against task.outputSchema here.
739
- // Known gap for default-mode Devin; see runbooks/harness-providers.md
740
- // ("Per-task outputSchema support"). Schema enforcement only happens on
741
- // the MCP path via store-progress.
742
- body.output = providerOutput;
788
+ const validation = await validateProviderOutputIfNeeded(config, taskId, providerOutput);
789
+ if (validation.ok) {
790
+ body.output = providerOutput;
791
+ } else {
792
+ status = "failed";
793
+ body.status = "failed";
794
+ body.failureReason = validation.failReason;
795
+ }
743
796
  } else {
744
797
  // Try structured output fallback if the task has an outputSchema
745
798
  const adapterType = provider ?? process.env.HARNESS_PROVIDER ?? "claude";
package/src/http/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { startSlackApp, stopSlackApp } from "../slack";
25
25
  import { initTelemetry, telemetry } from "../telemetry";
26
26
  import { getApiKey } from "../utils/api-key";
27
+ import { scrubSecrets } from "../utils/secret-scrubber";
27
28
  import { initWorkflows } from "../workflows";
28
29
  import { handleActiveSessions } from "./active-sessions";
29
30
  import { handleAgentRegister, handleAgentsRest } from "./agents";
@@ -68,6 +69,7 @@ import {
68
69
  getPathSegments,
69
70
  httpServerSemconvAttributes,
70
71
  parseQueryParams,
72
+ safeRequestUrlForLog,
71
73
  setCorsHeaders,
72
74
  } from "./utils";
73
75
  import { handleWebhooks } from "./webhooks";
@@ -124,7 +126,9 @@ const httpServer = createHttpServer(async (req, res) => {
124
126
  const logRequest = () => {
125
127
  const elapsed = (performance.now() - startTime).toFixed(1);
126
128
  const statusEmoji = statusCode >= 400 ? "⚠️" : "✓";
127
- console.log(`[HTTP] ${statusEmoji} ${req.method} ${req.url} → ${statusCode} (${elapsed}ms)`);
129
+ console.log(
130
+ `[HTTP] ${statusEmoji} ${req.method} ${safeRequestUrlForLog(req.url)} → ${statusCode} (${elapsed}ms)`,
131
+ );
128
132
  };
129
133
 
130
134
  // Ensure we log on response finish
@@ -132,7 +136,9 @@ const httpServer = createHttpServer(async (req, res) => {
132
136
 
133
137
  // Log errors
134
138
  res.on("error", (err) => {
135
- console.error(`[HTTP] ❌ ${req.method} ${req.url} → Error: ${err.message}`);
139
+ console.error(
140
+ `[HTTP] ❌ ${req.method} ${safeRequestUrlForLog(req.url)} → Error: ${scrubSecrets(err.message)}`,
141
+ );
136
142
  });
137
143
 
138
144
  await withRemoteContext(req.headers as Record<string, unknown>, async () => {
@@ -257,7 +263,9 @@ const httpServer = createHttpServer(async (req, res) => {
257
263
  span.setStatus({ code: 2, message: err instanceof Error ? err.message : String(err) });
258
264
  }
259
265
  const message = err instanceof Error ? err.message : String(err);
260
- console.error(`[HTTP] ❌ ${req.method} ${req.url} → ${message}`);
266
+ console.error(
267
+ `[HTTP] ❌ ${req.method} ${safeRequestUrlForLog(req.url)} → ${scrubSecrets(message)}`,
268
+ );
261
269
  if (!res.headersSent) {
262
270
  res.writeHead(500, { "Content-Type": "application/json" });
263
271
  res.end(JSON.stringify({ error: message }));
package/src/http/tasks.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  updateTaskVcs,
23
23
  } from "../be/db";
24
24
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
25
+ import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
25
26
  import { telemetry } from "../telemetry";
26
27
  import {
27
28
  type AgentTaskSource,
@@ -635,6 +636,22 @@ export async function handleTasks(
635
636
  filter: ({}, ctx) => ctx.deps.length > 0,
636
637
  conditions: [{ timeout_ms: 3_600_000 }], // 1 hour: task running time
637
638
  });
639
+
640
+ try {
641
+ const followUp = createWorkerTaskFollowUp({
642
+ task: result.task,
643
+ status: parsed.body.status,
644
+ output: parsed.body.output,
645
+ failureReason: parsed.body.failureReason,
646
+ });
647
+ if (followUp) {
648
+ console.log(
649
+ `[tasks.finish] Created follow-up task ${followUp.id.slice(0, 8)} for ${parsed.body.status} task ${parsed.params.id.slice(0, 8)}`,
650
+ );
651
+ }
652
+ } catch (err) {
653
+ console.warn(`[tasks.finish] Failed to create follow-up task: ${err}`);
654
+ }
638
655
  }
639
656
 
640
657
  json(res, {
package/src/http/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { getActiveTaskCount } from "../be/db";
3
+ import { scrubSecrets } from "../utils/secret-scrubber";
3
4
 
4
5
  export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
5
6
  // Echo the request Origin (rather than emitting `*`) so credentialed fetches
@@ -46,6 +47,22 @@ export function getPathSegments(url: string): string[] {
46
47
  return path.split("/").filter(Boolean);
47
48
  }
48
49
 
50
+ export function safeRequestUrlForLog(rawUrl: string | undefined): string {
51
+ if (!rawUrl) return "";
52
+
53
+ try {
54
+ const url = new URL(rawUrl, "http://localhost");
55
+ const params = Array.from(url.searchParams.keys());
56
+ if (params.length === 0) return url.pathname;
57
+
58
+ const redactedQuery = params.map((key) => `${key}=[REDACTED]`).join("&");
59
+ return `${url.pathname}?${redactedQuery}`;
60
+ } catch {
61
+ const pathOnly = rawUrl.split("?")[0] || rawUrl;
62
+ return scrubSecrets(pathOnly);
63
+ }
64
+ }
65
+
49
66
  /** Add capacity info to agent response */
50
67
  export function agentWithCapacity<T extends { id: string; maxTasks?: number }>(
51
68
  agent: T,
@@ -1,6 +1,18 @@
1
- import { getOAuthApp, getOAuthTokens, isTokenExpiringSoon } from "../be/db-queries/oauth";
1
+ import {
2
+ acquireOAuthRefreshLock,
3
+ getOAuthApp,
4
+ getOAuthTokens,
5
+ isTokenExpiringSoon,
6
+ releaseOAuthRefreshLock,
7
+ } from "../be/db-queries/oauth";
8
+ import type { OAuthTokens } from "../tracker/types";
2
9
  import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
3
10
 
11
+ const refreshLocks = new Map<string, Promise<void>>();
12
+ const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
13
+ const REFRESH_LOCK_WAIT_MS = 30 * 1000;
14
+ const REFRESH_LOCK_POLL_MS = 250;
15
+
4
16
  /**
5
17
  * Build an OAuthProviderConfig from the oauth_apps table for any provider.
6
18
  */
@@ -22,6 +34,41 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
22
34
  };
23
35
  }
24
36
 
37
+ async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>): Promise<T> {
38
+ const previous = refreshLocks.get(provider) ?? Promise.resolve();
39
+ let release!: () => void;
40
+ const current = new Promise<void>((resolve) => {
41
+ release = resolve;
42
+ });
43
+ const next = previous.catch(() => undefined).then(() => current);
44
+ refreshLocks.set(provider, next);
45
+
46
+ await previous.catch(() => undefined);
47
+
48
+ try {
49
+ return await fn();
50
+ } finally {
51
+ release();
52
+ if (refreshLocks.get(provider) === next) {
53
+ refreshLocks.delete(provider);
54
+ }
55
+ }
56
+ }
57
+
58
+ function sleep(ms: number): Promise<void> {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ function tokenRowChanged(current: OAuthTokens | null, observed: OAuthTokens | null): boolean {
63
+ if (!observed) return current !== null;
64
+ if (!current) return true;
65
+ return (
66
+ current.accessToken !== observed.accessToken ||
67
+ current.refreshToken !== observed.refreshToken ||
68
+ current.expiresAt !== observed.expiresAt
69
+ );
70
+ }
71
+
25
72
  /**
26
73
  * Ensure a valid OAuth token exists for the given provider.
27
74
  * If the token is expiring soon, attempt to refresh it.
@@ -57,16 +104,55 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
57
104
  */
58
105
  export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
59
106
  if (!isTokenExpiringSoon(provider, bufferMs)) return;
107
+ const observedTokens = getOAuthTokens(provider);
60
108
 
61
- const config = getOAuthConfig(provider);
62
- const tokens = getOAuthTokens(provider);
63
- if (!config || !tokens?.refreshToken) {
64
- console.warn(
65
- `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
66
- );
67
- return;
68
- }
109
+ await withProviderRefreshLock(provider, async () => {
110
+ const waitStartedAt = Date.now();
111
+
112
+ while (isTokenExpiringSoon(provider, bufferMs)) {
113
+ const tokens = getOAuthTokens(provider);
114
+ if (tokenRowChanged(tokens, observedTokens)) return;
115
+
116
+ const config = getOAuthConfig(provider);
117
+ if (!config || !tokens?.refreshToken) {
118
+ console.warn(
119
+ `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
120
+ );
121
+ return;
122
+ }
123
+
124
+ const lockOwner = acquireOAuthRefreshLock(provider, REFRESH_LOCK_TTL_MS);
125
+ if (!lockOwner) {
126
+ if (Date.now() - waitStartedAt > REFRESH_LOCK_WAIT_MS) {
127
+ throw new Error(`Timed out waiting for ${provider} OAuth token refresh lock`);
128
+ }
129
+ await sleep(REFRESH_LOCK_POLL_MS);
130
+ continue;
131
+ }
132
+
133
+ try {
134
+ const lockedTokens = getOAuthTokens(provider);
135
+ if (
136
+ !isTokenExpiringSoon(provider, bufferMs) ||
137
+ tokenRowChanged(lockedTokens, observedTokens)
138
+ ) {
139
+ return;
140
+ }
141
+
142
+ const lockedConfig = getOAuthConfig(provider);
143
+ if (!lockedConfig || !lockedTokens?.refreshToken) {
144
+ console.warn(
145
+ `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
146
+ );
147
+ return;
148
+ }
69
149
 
70
- await refreshAccessToken(config, tokens.refreshToken);
71
- console.log(`[OAuth] ${provider} token refreshed successfully`);
150
+ await refreshAccessToken(lockedConfig, lockedTokens.refreshToken);
151
+ console.log(`[OAuth] ${provider} token refreshed successfully`);
152
+ return;
153
+ } finally {
154
+ releaseOAuthRefreshLock(provider, lockOwner);
155
+ }
156
+ }
157
+ });
72
158
  }
@@ -316,6 +316,26 @@ function cleanupAgentsMdSymlink(cwd: string): void {
316
316
  }
317
317
  }
318
318
 
319
+ function extractTextContent(content: unknown): string {
320
+ if (typeof content === "string") return content.trim();
321
+ if (!Array.isArray(content)) return "";
322
+ return content
323
+ .filter(
324
+ (c): c is { type?: string; text?: string } =>
325
+ typeof c === "object" && c !== null && (c as { type?: string }).type === "text",
326
+ )
327
+ .map((c) => c.text || "")
328
+ .join("")
329
+ .trim();
330
+ }
331
+
332
+ export function extractPiAssistantText(message: unknown): string {
333
+ if (!message || typeof message !== "object") return "";
334
+ const msg = message as { role?: string; content?: unknown };
335
+ if (msg.role !== "assistant") return "";
336
+ return extractTextContent(msg.content);
337
+ }
338
+
319
339
  export class PiMonoSession implements ProviderSession {
320
340
  private listeners: Array<(event: ProviderEvent) => void> = [];
321
341
  private eventQueue: ProviderEvent[] = [];
@@ -327,6 +347,8 @@ export class PiMonoSession implements ProviderSession {
327
347
  private logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
328
348
  /** Track last emitted message text to avoid duplicates across turns */
329
349
  private lastEmittedMessage = "";
350
+ /** Last assistant text surfaced by pi-mono; used as runner fallback output. */
351
+ private lastAssistantText = "";
330
352
  /** Phase 7: wallclock start so we can populate `durationMs` on the cost row. */
331
353
  private sessionStartedAt: number = Date.now();
332
354
  /**
@@ -391,31 +413,27 @@ export class PiMonoSession implements ProviderSession {
391
413
  private handleAgentEvent(event: AgentSessionEvent): void {
392
414
  switch (event.type) {
393
415
  case "message_end": {
394
- // Extract text from the final message (skip duplicates across turns)
395
- const msg = event.message;
396
- if (msg && "content" in msg) {
397
- const text = Array.isArray(msg.content)
398
- ? msg.content
399
- .filter((c: unknown) => (c as { type: string }).type === "text")
400
- .map((c: unknown) => (c as { text?: string }).text || "")
401
- .join("")
402
- .trim()
403
- : String(msg.content || "").trim();
404
- if (text && text !== this.lastEmittedMessage) {
405
- const model = this.reportedModel();
406
- this.emit({
407
- type: "raw_log",
408
- content: JSON.stringify({
409
- type: "assistant",
410
- message: {
411
- role: "assistant",
412
- content: [{ type: "text", text }],
413
- model,
414
- },
415
- }),
416
- });
417
- this.lastEmittedMessage = text;
418
- }
416
+ // Pi emits message_end for user, assistant, and tool-result messages.
417
+ // Only assistant text should be printed or used as fallback output.
418
+ const text = extractPiAssistantText(event.message);
419
+ if (text) {
420
+ this.lastAssistantText = text;
421
+ }
422
+ if (text && text !== this.lastEmittedMessage) {
423
+ const model = this.reportedModel();
424
+ this.emit({
425
+ type: "raw_log",
426
+ content: JSON.stringify({
427
+ type: "assistant",
428
+ message: {
429
+ role: "assistant",
430
+ content: [{ type: "text", text }],
431
+ model,
432
+ },
433
+ }),
434
+ });
435
+ this.emit({ type: "message", role: "assistant", content: text });
436
+ this.lastEmittedMessage = text;
419
437
  }
420
438
  // Emit context_usage for dashboard tracking.
421
439
  // Phase 7: derive `outputTokens` from `SessionStats` delta (pi-ai's
@@ -522,6 +540,7 @@ export class PiMonoSession implements ProviderSession {
522
540
  exitCode: 0,
523
541
  sessionId: this._sessionId,
524
542
  cost,
543
+ output: this.lastAssistantText || undefined,
525
544
  isError: false,
526
545
  };
527
546
  } catch (err) {
package/src/server.ts CHANGED
@@ -43,6 +43,7 @@ import { registerMemoryGetTool } from "./tools/memory-get";
43
43
  import { registerMemoryRateTool } from "./tools/memory-rate";
44
44
  import { registerMemorySearchTool } from "./tools/memory-search";
45
45
  import { registerMyAgentInfoTool } from "./tools/my-agent-info";
46
+ import { registerGetOauthAccessTokenTool } from "./tools/oauth-access-token";
46
47
  import { registerPollTaskTool } from "./tools/poll-task";
47
48
  import { registerPostMessageTool } from "./tools/post-message";
48
49
  // Prompt template tools
@@ -199,6 +200,7 @@ export function createServer() {
199
200
 
200
201
  // Debug tools - always registered (self-guards with lead check)
201
202
  registerDbQueryTool(server);
203
+ registerGetOauthAccessTokenTool(server);
202
204
 
203
205
  // Swarm config tools - always registered (config management is fundamental)
204
206
  registerSetConfigTool(server);
@@ -0,0 +1,82 @@
1
+ import { createTaskExtended, getAgentById, getLeadAgent, getTaskAttachments } from "../be/db";
2
+ import { resolveTemplate } from "../prompts/resolver";
3
+ import type { AgentTask, TaskAttachment } from "../types";
4
+ // Side-effect import: registers task lifecycle templates in the in-memory registry.
5
+ import "../tools/templates";
6
+
7
+ function attachmentPointer(a: TaskAttachment): string {
8
+ switch (a.kind) {
9
+ case "url":
10
+ return a.url ?? "";
11
+ case "page":
12
+ return `page:${a.pageId ?? ""}`;
13
+ case "agent-fs":
14
+ return `agent-fs:${a.path ?? ""}`;
15
+ case "shared-fs":
16
+ return `shared-fs:${a.path ?? ""}`;
17
+ }
18
+ }
19
+
20
+ function formatAttachmentsBlock(attachments: TaskAttachment[]): string {
21
+ if (attachments.length === 0) return "";
22
+ const lines = attachments.map((a) => {
23
+ const tag = a.isPrimary ? "[primary] " : "";
24
+ const intent = a.intent ? ` (intent: ${a.intent})` : "";
25
+ return `- ${tag}${a.name} - ${attachmentPointer(a)}${intent}`;
26
+ });
27
+ return `\n\nAttachments (${attachments.length}):\n${lines.join("\n")}`;
28
+ }
29
+
30
+ export function createWorkerTaskFollowUp(args: {
31
+ task: AgentTask;
32
+ status: "completed" | "failed";
33
+ output?: string;
34
+ failureReason?: string;
35
+ }): AgentTask | null {
36
+ const { task, status, output, failureReason } = args;
37
+
38
+ if (task.workflowRunId) return null;
39
+
40
+ const taskAgent = getAgentById(task.agentId ?? "");
41
+ if (!taskAgent || taskAgent.isLead) return null;
42
+
43
+ const leadAgent = getLeadAgent();
44
+ if (!leadAgent) return null;
45
+
46
+ const agentName = taskAgent.name || task.agentId?.slice(0, 8) || "Unknown";
47
+ const taskDesc = task.task.slice(0, 200);
48
+
49
+ let followUpDescription: string;
50
+ if (status === "completed") {
51
+ const attachmentsBlock = formatAttachmentsBlock(getTaskAttachments(task.id));
52
+ const outputSummary = output
53
+ ? `${output.slice(0, 500)}${output.length > 500 ? "..." : ""}${attachmentsBlock}`
54
+ : `(no output)${attachmentsBlock}`;
55
+ const completedResult = resolveTemplate("task.worker.completed", {
56
+ agent_name: agentName,
57
+ task_desc: taskDesc,
58
+ output_summary: outputSummary,
59
+ task_id: task.id,
60
+ });
61
+ followUpDescription = completedResult.text;
62
+ } else {
63
+ const reason = failureReason || "(no reason given)";
64
+ const failedResult = resolveTemplate("task.worker.failed", {
65
+ agent_name: agentName,
66
+ task_desc: taskDesc,
67
+ failure_reason: reason,
68
+ task_id: task.id,
69
+ });
70
+ followUpDescription = failedResult.text;
71
+ }
72
+
73
+ return createTaskExtended(followUpDescription, {
74
+ agentId: leadAgent.id,
75
+ source: "system",
76
+ taskType: "follow-up",
77
+ parentTaskId: task.id,
78
+ slackChannelId: task.slackChannelId,
79
+ slackThreadTs: task.slackThreadTs,
80
+ slackUserId: task.slackUserId,
81
+ });
82
+ }
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { getAgentModelDisplay } from "../../ui/src/lib/agents-list-model-display";
2
+ import {
3
+ getAgentModelDisplay,
4
+ getAgentModelPresentation,
5
+ } from "../../ui/src/lib/agents-list-model-display";
3
6
 
4
7
  describe("agents list model display", () => {
5
8
  test("shows configured and last-used models when they diverge", () => {
@@ -30,4 +33,13 @@ describe("agents list model display", () => {
30
33
  expect(display.primary).toBe("claude-opus-4-7");
31
34
  expect(display.diverged).toBe(false);
32
35
  });
36
+
37
+ test("presents known provider-prefixed model ids as readable labels", () => {
38
+ expect(getAgentModelPresentation("openrouter/deepseek/deepseek-v4-flash")).toEqual({
39
+ raw: "openrouter/deepseek/deepseek-v4-flash",
40
+ label: "DeepSeek V4 Flash",
41
+ provider: "OpenRouter",
42
+ providerId: "openrouter",
43
+ });
44
+ });
33
45
  });