@desplega.ai/agent-swarm 1.71.2 → 1.72.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.
Files changed (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
@@ -1,5 +1,10 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
+ import { canClaim } from "@/be/budget-admission";
4
+ import {
5
+ type BudgetRefusalContext,
6
+ emitBudgetRefusalSideEffects,
7
+ } from "@/be/budget-refusal-notify";
3
8
  import {
4
9
  acceptTask,
5
10
  checkDependencies,
@@ -14,12 +19,13 @@ import {
14
19
  moveTaskFromBacklog,
15
20
  moveTaskToBacklog,
16
21
  reassociateSessionLogs,
22
+ recordBudgetRefusalNotification,
17
23
  rejectTask,
18
24
  releaseTask,
19
25
  updateTaskClaudeSessionId,
20
26
  } from "@/be/db";
21
27
  import { createToolRegistrar } from "@/tools/utils";
22
- import { AgentTaskSchema } from "@/types";
28
+ import { AgentTaskSchema, BudgetRefusalCauseSchema } from "@/types";
23
29
 
24
30
  const TaskActionSchema = z.enum([
25
31
  "create",
@@ -83,6 +89,14 @@ export const registerTaskActionTool = (server: McpServer) => {
83
89
  success: z.boolean(),
84
90
  message: z.string(),
85
91
  task: AgentTaskSchema.optional(),
92
+ // Phase 3: budget-admission refusal fields. Populated only on
93
+ // `accept` action when the per-agent or global daily budget is blown.
94
+ refusalCause: BudgetRefusalCauseSchema.optional(),
95
+ agentSpend: z.number().optional(),
96
+ agentBudget: z.number().optional(),
97
+ globalSpend: z.number().optional(),
98
+ globalBudget: z.number().optional(),
99
+ resetAt: z.string().optional(),
86
100
  }),
87
101
  },
88
102
  async (input, requestInfo, _meta) => {
@@ -241,6 +255,60 @@ export const registerTaskActionTool = (server: McpServer) => {
241
255
  message: `Task "${taskId}" has unmet dependencies: ${blockedBy.join(", ")}. Cannot accept until dependencies are completed.`,
242
256
  };
243
257
  }
258
+ // Budget admission gate (Phase 3). Same in-transaction placement
259
+ // as the /api/poll gates so capacity AND budget share atomicity.
260
+ // Phase 5: record dedup row + capture side-effect context for the
261
+ // after-commit lead follow-up + workflow event-bus emit.
262
+ const admission = canClaim(agentId, new Date());
263
+ if (!admission.allowed) {
264
+ const causeMsg =
265
+ admission.cause === "agent"
266
+ ? "agent daily budget exceeded"
267
+ : "global daily budget exceeded";
268
+ const utcDate = new Date().toISOString().slice(0, 10);
269
+ const dedup = recordBudgetRefusalNotification({
270
+ taskId,
271
+ date: utcDate,
272
+ agentId,
273
+ cause: admission.cause,
274
+ agentSpendUsd: admission.agentSpend,
275
+ agentBudgetUsd: admission.agentBudget,
276
+ globalSpendUsd: admission.globalSpend,
277
+ globalBudgetUsd: admission.globalBudget,
278
+ });
279
+ return {
280
+ success: false,
281
+ message: `Refused: ${causeMsg}. Resets at ${admission.resetAt}.`,
282
+ refusalCause: admission.cause,
283
+ ...(admission.agentSpend !== undefined && { agentSpend: admission.agentSpend }),
284
+ ...(admission.agentBudget !== undefined && { agentBudget: admission.agentBudget }),
285
+ ...(admission.globalSpend !== undefined && { globalSpend: admission.globalSpend }),
286
+ ...(admission.globalBudget !== undefined && {
287
+ globalBudget: admission.globalBudget,
288
+ }),
289
+ resetAt: admission.resetAt,
290
+ refusalSideEffects: {
291
+ context: {
292
+ task: {
293
+ id: existingTask.id,
294
+ task: existingTask.task,
295
+ slackChannelId: existingTask.slackChannelId,
296
+ slackThreadTs: existingTask.slackThreadTs,
297
+ slackUserId: existingTask.slackUserId,
298
+ },
299
+ agentId,
300
+ date: utcDate,
301
+ cause: admission.cause,
302
+ agentSpendUsd: admission.agentSpend,
303
+ agentBudgetUsd: admission.agentBudget,
304
+ globalSpendUsd: admission.globalSpend,
305
+ globalBudgetUsd: admission.globalBudget,
306
+ resetAt: admission.resetAt,
307
+ } satisfies BudgetRefusalContext,
308
+ inserted: dedup.inserted,
309
+ },
310
+ };
311
+ }
244
312
  const acceptedTask = acceptTask(taskId, agentId);
245
313
  if (!acceptedTask) {
246
314
  return { success: false, message: `Failed to accept task "${taskId}".` };
@@ -334,11 +402,33 @@ export const registerTaskActionTool = (server: McpServer) => {
334
402
 
335
403
  const result = txn();
336
404
 
405
+ // Phase 5: when the accept gate refused, run after-commit side
406
+ // effects (lead follow-up + workflow bus). The dedup row was recorded
407
+ // inside the txn; this just consumes the captured context.
408
+ if (
409
+ "refusalSideEffects" in result &&
410
+ result.refusalSideEffects &&
411
+ typeof result.refusalSideEffects === "object"
412
+ ) {
413
+ const sideEffects = result.refusalSideEffects as {
414
+ context: BudgetRefusalContext;
415
+ inserted: boolean;
416
+ };
417
+ emitBudgetRefusalSideEffects(sideEffects.context, sideEffects.inserted);
418
+ }
419
+
420
+ // Strip the internal-only `refusalSideEffects` field from the wire
421
+ // response — workers receive only the public refusal envelope.
422
+ const { refusalSideEffects: _omit, ...publicResult } = result as {
423
+ refusalSideEffects?: unknown;
424
+ [key: string]: unknown;
425
+ };
426
+
337
427
  return {
338
428
  content: [{ type: "text", text: result.message }],
339
429
  structuredContent: {
340
430
  yourAgentId: agentId,
341
- ...result,
431
+ ...publicResult,
342
432
  },
343
433
  };
344
434
  },
@@ -84,3 +84,32 @@ Decide whether to reassign, retry, or handle the failure. Use \`get-task-details
84
84
  ],
85
85
  category: "task_lifecycle",
86
86
  });
87
+
88
+ // ============================================================================
89
+ // Budget refusal follow-up (Phase 5: created when an agent is refused due to
90
+ // per-agent or global daily budget exhaustion)
91
+ // ============================================================================
92
+
93
+ registerTemplate({
94
+ eventType: "task.budget.refused",
95
+ header: "",
96
+ defaultBody: `Budget refusal \u2014 task is blocked.
97
+
98
+ Cause: {{cause}}
99
+ Agent: {{agent_name}}
100
+ Task: {{task_desc}}
101
+ Spend / budget: {{spend_summary}}
102
+ Resets at: {{reset_at}}
103
+
104
+ Decide whether to raise the budget, reassign, or wait for the daily reset.
105
+ Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
106
+ variables: [
107
+ { name: "cause", description: "'agent' or 'global'" },
108
+ { name: "agent_name", description: "Refusing agent name or ID prefix" },
109
+ { name: "task_desc", description: "Task description (truncated to 200 chars)" },
110
+ { name: "spend_summary", description: 'Formatted "$X / $Y" pair' },
111
+ { name: "reset_at", description: "UTC reset time (human readable)" },
112
+ { name: "task_id", description: "Original task ID" },
113
+ ],
114
+ category: "task_lifecycle",
115
+ });
package/src/types.ts CHANGED
@@ -68,6 +68,32 @@ export const AgentTaskSourceSchema = z.enum([
68
68
  ]);
69
69
  export type AgentTaskSource = z.infer<typeof AgentTaskSourceSchema>;
70
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Harness Provider
73
+ // ---------------------------------------------------------------------------
74
+ // String identifiers accepted by `HARNESS_PROVIDER` and the
75
+ // `createProviderAdapter` factory in `src/providers/index.ts`. Keep this in
76
+ // sync with the factory's switch and the unknown-provider error message.
77
+ export const ProviderNameSchema = z.enum(["claude", "codex", "pi", "devin", "claude-managed"]);
78
+ export type ProviderName = z.infer<typeof ProviderNameSchema>;
79
+
80
+ export type DevinProviderMeta = {
81
+ sessionUrl: string;
82
+ maxAcuLimit?: number;
83
+ acuCostUsd?: number;
84
+ };
85
+
86
+ // These providers do not have metadata yet.
87
+ type NoProviderMeta = Record<string, never>;
88
+
89
+ export type ProviderMetaMap = {
90
+ devin: DevinProviderMeta;
91
+ claude: NoProviderMeta;
92
+ codex: NoProviderMeta;
93
+ pi: NoProviderMeta;
94
+ "claude-managed": NoProviderMeta;
95
+ };
96
+
71
97
  export const AgentTaskSchema = z.object({
72
98
  id: z.uuid(),
73
99
  agentId: z.uuid().nullable(), // Nullable for unassigned tasks
@@ -169,6 +195,10 @@ export const AgentTaskSchema = z.object({
169
195
  // agent-swarm package version at task creation time. Enables benchmarking
170
196
  // performance across releases. Nullable for rows created before tracking was added.
171
197
  swarmVersion: z.string().optional(),
198
+
199
+ // Provider tracking — which harness provider ran this task
200
+ provider: ProviderNameSchema.optional(),
201
+ providerMeta: z.record(z.string(), z.unknown()).optional(),
172
202
  });
173
203
 
174
204
  // ============================================================================
@@ -365,6 +395,11 @@ export const AgentLogEventTypeSchema = z.enum([
365
395
  "service_registered",
366
396
  "service_unregistered",
367
397
  "service_status_change",
398
+ // Phase 6: budget / pricing operator-mutation audit log events
399
+ "budget.upserted",
400
+ "budget.deleted",
401
+ "pricing.inserted",
402
+ "pricing.deleted",
368
403
  ]);
369
404
 
370
405
  export const AgentLogSchema = z.object({
@@ -396,6 +431,9 @@ export const SessionLogSchema = z.object({
396
431
  export type SessionLog = z.infer<typeof SessionLogSchema>;
397
432
 
398
433
  // Session Cost Types (aggregated cost data per session)
434
+ export const SessionCostSourceSchema = z.enum(["harness", "pricing-table"]);
435
+ export type SessionCostSource = z.infer<typeof SessionCostSourceSchema>;
436
+
399
437
  export const SessionCostSchema = z.object({
400
438
  id: z.uuid(),
401
439
  sessionId: z.string(),
@@ -410,6 +448,10 @@ export const SessionCostSchema = z.object({
410
448
  numTurns: z.number().int().min(1),
411
449
  model: z.string(),
412
450
  isError: z.boolean().default(false),
451
+ // Phase 6: where the recorded totalCostUsd came from. New rows write the
452
+ // actual source ('pricing-table' when the API recomputed Codex USD from DB
453
+ // pricing rows, 'harness' otherwise). Defaults to 'harness' for back-compat.
454
+ costSource: SessionCostSourceSchema.default("harness"),
413
455
  createdAt: z.iso.datetime(),
414
456
  });
415
457
 
@@ -1122,3 +1164,77 @@ export const ContextSnapshotSchema = z.object({
1122
1164
  });
1123
1165
 
1124
1166
  export type ContextSnapshot = z.infer<typeof ContextSnapshotSchema>;
1167
+
1168
+ // ============================================================================
1169
+ // Budgets + Pricing (per-agent daily cost budget — V1)
1170
+ // ============================================================================
1171
+ //
1172
+ // Timestamp convention for these schemas: number = epoch milliseconds (UTC).
1173
+ // This is a deliberate divergence from the rest of types.ts (which uses
1174
+ // `z.iso.datetime()` strings) so that the price-book "largest
1175
+ // effective_from <= now" lookup is a pure integer comparison. Matches the
1176
+ // SQL columns in migration 046_budgets_and_pricing.sql verbatim.
1177
+
1178
+ export const BudgetScopeSchema = z.enum(["global", "agent"]);
1179
+ export type BudgetScope = z.infer<typeof BudgetScopeSchema>;
1180
+
1181
+ export const BudgetSchema = z.object({
1182
+ scope: BudgetScopeSchema,
1183
+ scopeId: z.string(), // '' (empty string) for the global row
1184
+ dailyBudgetUsd: z.number().nonnegative(),
1185
+ createdAt: z.number(), // epoch ms
1186
+ lastUpdatedAt: z.number(), // epoch ms
1187
+ });
1188
+ export type Budget = z.infer<typeof BudgetSchema>;
1189
+
1190
+ export const PricingProviderSchema = z.enum(["claude", "codex", "pi"]);
1191
+ export type PricingProvider = z.infer<typeof PricingProviderSchema>;
1192
+
1193
+ export const PricingTokenClassSchema = z.enum(["input", "cached_input", "output"]);
1194
+ export type PricingTokenClass = z.infer<typeof PricingTokenClassSchema>;
1195
+
1196
+ export const PricingRowSchema = z.object({
1197
+ provider: PricingProviderSchema,
1198
+ model: z.string(),
1199
+ tokenClass: PricingTokenClassSchema,
1200
+ effectiveFrom: z.number().nonnegative(), // epoch ms; 0 = seed
1201
+ pricePerMillionUsd: z.number().nonnegative(),
1202
+ createdAt: z.number(), // epoch ms
1203
+ lastUpdatedAt: z.number(), // epoch ms
1204
+ });
1205
+ export type PricingRow = z.infer<typeof PricingRowSchema>;
1206
+
1207
+ export const BudgetRefusalCauseSchema = z.enum(["agent", "global"]);
1208
+ export type BudgetRefusalCause = z.infer<typeof BudgetRefusalCauseSchema>;
1209
+
1210
+ export const BudgetRefusalNotificationSchema = z.object({
1211
+ taskId: z.string(),
1212
+ date: z.string(), // 'YYYY-MM-DD' UTC
1213
+ agentId: z.string(),
1214
+ cause: BudgetRefusalCauseSchema,
1215
+ agentSpendUsd: z.number().nullable().optional(),
1216
+ agentBudgetUsd: z.number().nullable().optional(),
1217
+ globalSpendUsd: z.number().nullable().optional(),
1218
+ globalBudgetUsd: z.number().nullable().optional(),
1219
+ followUpTaskId: z.string().nullable().optional(),
1220
+ createdAt: z.number(), // epoch ms
1221
+ });
1222
+ export type BudgetRefusalNotification = z.infer<typeof BudgetRefusalNotificationSchema>;
1223
+
1224
+ /**
1225
+ * Phase 3 — `budget_refused` is the new variant of the `/api/poll` trigger
1226
+ * envelope returned when an admission gate (`canClaim`) refuses to let the
1227
+ * agent take a task. Older workers receiving this discriminator fall through
1228
+ * to default polling without back-off (degrades gracefully); Phase 4 teaches
1229
+ * the runner to recognize it.
1230
+ */
1231
+ export const BudgetRefusedTriggerSchema = z.object({
1232
+ type: z.literal("budget_refused"),
1233
+ cause: BudgetRefusalCauseSchema,
1234
+ agentSpend: z.number().optional(),
1235
+ agentBudget: z.number().optional(),
1236
+ globalSpend: z.number().optional(),
1237
+ globalBudget: z.number().optional(),
1238
+ resetAt: z.string(), // ISO 8601, next UTC midnight
1239
+ });
1240
+ export type BudgetRefusedTrigger = z.infer<typeof BudgetRefusedTriggerSchema>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Phase 4 — exponential back-off for `budget_refused` poll responses.
3
+ *
4
+ * The worker poll loop short-circuits on `trigger.type === "budget_refused"`
5
+ * to avoid busy-looping the API while the agent is over-budget. Each
6
+ * consecutive refusal doubles the sleep, capped at 5 minutes (per the
7
+ * scoping decision in the per-agent-daily-cost-budget plan).
8
+ *
9
+ * This module is a pure helper so it can be unit-tested in isolation
10
+ * without standing up the full poll loop.
11
+ */
12
+
13
+ /** Hard cap on back-off, per the plan (5 minutes). */
14
+ export const BUDGET_BACKOFF_CAP_MS = 5 * 60 * 1000;
15
+
16
+ /**
17
+ * Compute the back-off delay for the Nth consecutive `budget_refused`
18
+ * trigger. The first refusal sleeps `basePollMs`; each subsequent one
19
+ * doubles, capped at {@link BUDGET_BACKOFF_CAP_MS}.
20
+ *
21
+ * @param consecutiveRefusals - 1-indexed count of consecutive refusals
22
+ * (i.e. the Nth refusal in a row, including the current one).
23
+ * Must be >= 1; values < 1 fall back to `basePollMs`.
24
+ * @param basePollMs - Base poll interval (today's `PollIntervalMs`, ~2s).
25
+ * @returns Sleep duration in milliseconds, capped at 5 minutes.
26
+ */
27
+ export function computeBudgetBackoffMs(consecutiveRefusals: number, basePollMs: number): number {
28
+ const n = Math.max(1, Math.floor(consecutiveRefusals));
29
+ // 2 ** (n-1) grows quickly; cap before multiplying to avoid Infinity for
30
+ // pathological inputs. JS handles 2 ** 30 fine but cap-first is cleaner.
31
+ const exponent = Math.min(30, n - 1);
32
+ const raw = basePollMs * 2 ** exponent;
33
+ return Math.min(BUDGET_BACKOFF_CAP_MS, raw);
34
+ }
@@ -5,6 +5,7 @@ export const CREDENTIAL_POOL_VARS = [
5
5
  "OPENROUTER_API_KEY",
6
6
  "OPENAI_API_KEY",
7
7
  "CODEX_OAUTH",
8
+ "DEVIN_API_KEY",
8
9
  ] as const;
9
10
 
10
11
  /**
@@ -23,6 +24,7 @@ export const PROVIDER_CREDENTIAL_VARS: Record<string, readonly string[]> = {
23
24
  // pi-mono accepts either router or anthropic keys
24
25
  pi: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"],
25
26
  codex: ["OPENAI_API_KEY", "CODEX_OAUTH"],
27
+ devin: ["DEVIN_API_KEY"],
26
28
  };
27
29
 
28
30
  /**
@@ -43,6 +45,8 @@ export function deriveProviderFromKeyType(keyType: string): string {
43
45
  case "OPENAI_API_KEY":
44
46
  case "CODEX_OAUTH":
45
47
  return "codex";
48
+ case "DEVIN_API_KEY":
49
+ return "devin";
46
50
  default:
47
51
  return "claude";
48
52
  }
@@ -0,0 +1,9 @@
1
+ import type { ProviderMetaMap, ProviderName } from "@/types";
2
+
3
+ export function parseProviderMeta<TProvider extends ProviderName>(
4
+ rawProvider: TProvider | null | undefined,
5
+ rawMeta: string | undefined | null,
6
+ ): ProviderMetaMap[TProvider] | undefined {
7
+ if (!rawMeta || !rawProvider) return undefined;
8
+ return JSON.parse(rawMeta) as ProviderMetaMap[TProvider];
9
+ }