@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Thin REST client for Devin v3 API endpoints.
3
+ *
4
+ * All functions authenticate via Bearer token and target the organization-scoped
5
+ * v3 routes. The base URL defaults to `https://api.devin.ai` but can be
6
+ * overridden via `DEVIN_API_BASE_URL` for testing or on-prem deployments.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type DevinSessionStatus =
14
+ | "new"
15
+ | "creating"
16
+ | "claimed"
17
+ | "running"
18
+ | "exit"
19
+ | "error"
20
+ | "suspended"
21
+ | "resuming";
22
+
23
+ export type DevinStatusDetail =
24
+ | "working"
25
+ | "waiting_for_user"
26
+ | "waiting_for_approval"
27
+ | "finished"
28
+ | "inactivity"
29
+ | "user_request"
30
+ | "usage_limit_exceeded"
31
+ | "out_of_credits"
32
+ | "out_of_quota"
33
+ | "no_quota_allocation"
34
+ | "payment_declined"
35
+ | "org_usage_limit_exceeded"
36
+ | "error";
37
+
38
+ export interface DevinSessionCreateRequest {
39
+ prompt: string;
40
+ playbook_id?: string;
41
+ repos?: string[];
42
+ structured_output_schema?: object;
43
+ tags?: string[];
44
+ title?: string;
45
+ max_acu_limit?: number;
46
+ bypass_approval?: boolean;
47
+ session_secrets?: Array<{ key: string; value: string; sensitive?: boolean }>;
48
+ }
49
+
50
+ export interface DevinSessionResponse {
51
+ session_id: string;
52
+ url: string;
53
+ status: DevinSessionStatus;
54
+ status_detail?: DevinStatusDetail;
55
+ structured_output?: unknown;
56
+ pull_requests?: Array<{ pr_url: string; pr_state: string }>;
57
+ acus_consumed?: number;
58
+ title?: string;
59
+ tags?: string[];
60
+ created_at: number;
61
+ updated_at: number;
62
+ }
63
+
64
+ export interface DevinSessionMessage {
65
+ event_id: string;
66
+ source: "devin" | "user";
67
+ message: string;
68
+ created_at: number;
69
+ }
70
+
71
+ export interface DevinMessagesResponse {
72
+ items: DevinSessionMessage[];
73
+ end_cursor: string | null;
74
+ has_next_page: boolean;
75
+ total: number | null;
76
+ }
77
+
78
+ export interface DevinPlaybookCreateRequest {
79
+ title: string;
80
+ body: string;
81
+ }
82
+
83
+ export interface DevinPlaybookResponse {
84
+ playbook_id: string;
85
+ title: string;
86
+ body: string;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Helpers
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function baseUrl(): string {
94
+ return process.env.DEVIN_API_BASE_URL ?? "https://api.devin.ai";
95
+ }
96
+
97
+ function headers(apiKey: string): Record<string, string> {
98
+ return {
99
+ Authorization: `Bearer ${apiKey}`,
100
+ "Content-Type": "application/json",
101
+ };
102
+ }
103
+
104
+ async function assertOk(res: Response, label: string): Promise<void> {
105
+ if (!res.ok) {
106
+ let body = "";
107
+ try {
108
+ body = await res.text();
109
+ } catch {
110
+ // Ignore read errors.
111
+ }
112
+ throw new Error(`[devin-api] ${label} failed: HTTP ${res.status} — ${body}`);
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // API functions
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Create a new Devin session. */
121
+ export async function createSession(
122
+ orgId: string,
123
+ apiKey: string,
124
+ request: DevinSessionCreateRequest,
125
+ ): Promise<DevinSessionResponse> {
126
+ const res = await fetch(`${baseUrl()}/v3/organizations/${orgId}/sessions`, {
127
+ method: "POST",
128
+ headers: headers(apiKey),
129
+ body: JSON.stringify(request),
130
+ });
131
+ await assertOk(res, "createSession");
132
+ return (await res.json()) as DevinSessionResponse;
133
+ }
134
+
135
+ /** Get the current state of a Devin session. */
136
+ export async function getSession(
137
+ orgId: string,
138
+ apiKey: string,
139
+ sessionId: string,
140
+ ): Promise<DevinSessionResponse> {
141
+ const res = await fetch(`${baseUrl()}/v3/organizations/${orgId}/sessions/${sessionId}`, {
142
+ method: "GET",
143
+ headers: headers(apiKey),
144
+ });
145
+ await assertOk(res, "getSession");
146
+ return (await res.json()) as DevinSessionResponse;
147
+ }
148
+
149
+ /** Send a message to a running Devin session. */
150
+ export async function sendMessage(
151
+ orgId: string,
152
+ apiKey: string,
153
+ sessionId: string,
154
+ message: string,
155
+ ): Promise<void> {
156
+ const res = await fetch(`${baseUrl()}/v3/organizations/${orgId}/sessions/${sessionId}/messages`, {
157
+ method: "POST",
158
+ headers: headers(apiKey),
159
+ body: JSON.stringify({ message }),
160
+ });
161
+ await assertOk(res, "sendMessage");
162
+ }
163
+
164
+ /** Archive (terminate) a Devin session. */
165
+ export async function archiveSession(
166
+ orgId: string,
167
+ apiKey: string,
168
+ sessionId: string,
169
+ ): Promise<void> {
170
+ const res = await fetch(`${baseUrl()}/v3/organizations/${orgId}/sessions/${sessionId}/archive`, {
171
+ method: "POST",
172
+ headers: headers(apiKey),
173
+ });
174
+ await assertOk(res, "archiveSession");
175
+ }
176
+
177
+ /** Fetch session messages (cursor-based pagination). */
178
+ export async function getSessionMessages(
179
+ orgId: string,
180
+ apiKey: string,
181
+ sessionId: string,
182
+ after?: string,
183
+ ): Promise<DevinMessagesResponse> {
184
+ const params = new URLSearchParams({ first: "200" });
185
+ if (after) params.set("after", after);
186
+ const res = await fetch(
187
+ `${baseUrl()}/v3/organizations/${orgId}/sessions/${sessionId}/messages?${params}`,
188
+ { method: "GET", headers: headers(apiKey) },
189
+ );
190
+ await assertOk(res, "getSessionMessages");
191
+ return (await res.json()) as DevinMessagesResponse;
192
+ }
193
+
194
+ /** Create a new playbook. */
195
+ export async function createPlaybook(
196
+ orgId: string,
197
+ apiKey: string,
198
+ request: DevinPlaybookCreateRequest,
199
+ ): Promise<DevinPlaybookResponse> {
200
+ const res = await fetch(`${baseUrl()}/v3/organizations/${orgId}/playbooks`, {
201
+ method: "POST",
202
+ headers: headers(apiKey),
203
+ body: JSON.stringify(request),
204
+ });
205
+ await assertOk(res, "createPlaybook");
206
+ return (await res.json()) as DevinPlaybookResponse;
207
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Playbook cache helper for Devin provider.
3
+ *
4
+ * Maintains a SHA-256 hash → playbook_id cache backed by swarm_config so
5
+ * playbook IDs survive process restarts. An in-memory Map sits in front
6
+ * to avoid repeated HTTP round-trips within the same process.
7
+ */
8
+
9
+ import { createPlaybook } from "./devin-api";
10
+
11
+ const CONFIG_KEY_PREFIX = "devin_playbook_";
12
+
13
+ /** In-memory cache: SHA-256 hash of body -> playbook_id */
14
+ const playbookCache = new Map<string, string>();
15
+
16
+ async function loadFromConfig(
17
+ swarmApiUrl: string,
18
+ swarmApiKey: string,
19
+ hash: string,
20
+ ): Promise<string | null> {
21
+ try {
22
+ const key = `${CONFIG_KEY_PREFIX}${hash}`;
23
+ const res = await fetch(`${swarmApiUrl}/api/config/resolved?key=${key}`, {
24
+ headers: { Authorization: `Bearer ${swarmApiKey}` },
25
+ });
26
+ if (!res.ok) return null;
27
+ const data = (await res.json()) as { configs: Array<{ key: string; value: string }> };
28
+ const entry = data.configs?.find((c) => c.key === key);
29
+ return entry?.value ?? null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function saveToConfig(
36
+ swarmApiUrl: string,
37
+ swarmApiKey: string,
38
+ hash: string,
39
+ playbookId: string,
40
+ ): Promise<void> {
41
+ await fetch(`${swarmApiUrl}/api/config`, {
42
+ method: "PUT",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ Authorization: `Bearer ${swarmApiKey}`,
46
+ },
47
+ body: JSON.stringify({
48
+ scope: "global",
49
+ key: `${CONFIG_KEY_PREFIX}${hash}`,
50
+ value: playbookId,
51
+ description: `Devin playbook cache (hash: ${hash.slice(0, 12)}...)`,
52
+ }),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Return the playbook_id for the given body, creating the playbook via the
58
+ * Devin API if it has not been seen before.
59
+ */
60
+ export async function getOrCreatePlaybook(
61
+ orgId: string,
62
+ apiKey: string,
63
+ title: string,
64
+ body: string,
65
+ swarmApiUrl?: string,
66
+ swarmApiKey?: string,
67
+ ): Promise<string> {
68
+ const hash = new Bun.CryptoHasher("sha256").update(body).digest("hex");
69
+
70
+ const cached = playbookCache.get(hash);
71
+ if (cached) return cached;
72
+
73
+ if (swarmApiUrl && swarmApiKey) {
74
+ const persisted = await loadFromConfig(swarmApiUrl, swarmApiKey, hash);
75
+ if (persisted) {
76
+ playbookCache.set(hash, persisted);
77
+ return persisted;
78
+ }
79
+ }
80
+
81
+ const response = await createPlaybook(orgId, apiKey, { title, body });
82
+ playbookCache.set(hash, response.playbook_id);
83
+
84
+ if (swarmApiUrl && swarmApiKey) {
85
+ saveToConfig(swarmApiUrl, swarmApiKey, hash, response.playbook_id).catch((err) =>
86
+ console.warn(`[devin] playbook cache save failed: ${err}`),
87
+ );
88
+ }
89
+
90
+ return response.playbook_id;
91
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Devin skill resolver.
3
+ *
4
+ * Devin has no native skill system, so we inline SKILL.md content into the
5
+ * prompt before sending it to the Devin API.
6
+ *
7
+ * If the very first line of the prompt matches `@skills:<name> <args>`, we
8
+ * look up `${skillsDir}/<name>/SKILL.md` and (if found) inline its contents
9
+ * at the top of the new prompt, with the rest of the original prompt
10
+ * preserved as "User request: ...".
11
+ *
12
+ * This mirrors the pattern in codex-skill-resolver.ts.
13
+ */
14
+
15
+ import { join } from "node:path";
16
+ import type { ProviderEvent } from "./types";
17
+
18
+ /**
19
+ * Regex matching a leading `@skills:<name>` on a single line.
20
+ *
21
+ * - Must start with `@skills:`
22
+ * - Skill name is `[a-z0-9:_-]+`
23
+ * - Optional trailing args captured greedily in group 2
24
+ */
25
+ const SKILL_COMMAND_REGEX = /^@skills:([a-z0-9:_-]+)(?:\s+(.*))?$/;
26
+
27
+ const MAX_SKILL_CHARS = Number(process.env.MAX_SKILL_CHARS) || 100_000;
28
+
29
+ /**
30
+ * Default skills directory — `plugin/pi-skills/` relative to the project root.
31
+ *
32
+ * Precedence:
33
+ * 1. `DEVIN_SKILLS_DIR` env var (useful for tests / custom installs)
34
+ * 2. `<project-root>/plugin/pi-skills`
35
+ */
36
+ function defaultSkillsDir(): string {
37
+ if (process.env.DEVIN_SKILLS_DIR) {
38
+ return process.env.DEVIN_SKILLS_DIR;
39
+ }
40
+ // import.meta.dir is src/providers/ — go up two levels to project root
41
+ return join(import.meta.dir, "..", "..", "plugin", "pi-skills");
42
+ }
43
+
44
+ /**
45
+ * Inline a skill into the prompt if the first line matches `@skills:<name>`.
46
+ *
47
+ * @param prompt - Raw prompt from the runner
48
+ * @param skillsDir - Absolute path to the skills directory. Defaults to
49
+ * `plugin/pi-skills/` in the project root.
50
+ * @param emit - Optional callback to surface warnings via `raw_stderr` events.
51
+ * @returns The rewritten prompt, or the original if there's nothing to inline.
52
+ */
53
+ export async function resolveDevinPrompt(
54
+ prompt: string,
55
+ skillsDir?: string,
56
+ emit?: (event: ProviderEvent) => void,
57
+ ): Promise<string> {
58
+ if (!prompt) {
59
+ return prompt;
60
+ }
61
+
62
+ // Split on the FIRST newline only; the remainder is preserved verbatim.
63
+ const newlineIdx = prompt.indexOf("\n");
64
+ const firstLineRaw = newlineIdx === -1 ? prompt : prompt.slice(0, newlineIdx);
65
+ const rest = newlineIdx === -1 ? "" : prompt.slice(newlineIdx + 1);
66
+
67
+ // Detect the @skills: command on the first line (trimmed — tolerate leading ws).
68
+ const match = SKILL_COMMAND_REGEX.exec(firstLineRaw.trim());
69
+ if (!match || !match[1]) {
70
+ return prompt;
71
+ }
72
+
73
+ const commandName: string = match[1];
74
+ const trailingArgs: string = match[2] ?? "";
75
+ const dir = skillsDir ?? defaultSkillsDir();
76
+ const skillPath = join(dir, commandName, "SKILL.md");
77
+
78
+ const file = Bun.file(skillPath);
79
+ const exists = await file.exists();
80
+ if (!exists) {
81
+ emit?.({
82
+ type: "raw_stderr",
83
+ content: `[devin] skill resolver: SKILL.md not found for @skills:${commandName} (looked in ${skillPath})\n`,
84
+ });
85
+ return prompt;
86
+ }
87
+
88
+ let skillContent: string;
89
+ try {
90
+ skillContent = await file.text();
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ emit?.({
94
+ type: "raw_stderr",
95
+ content: `[devin] skill resolver: failed to read SKILL.md for @skills:${commandName}: ${message}\n`,
96
+ });
97
+ return prompt;
98
+ }
99
+
100
+ if (skillContent.length > MAX_SKILL_CHARS) {
101
+ emit?.({
102
+ type: "raw_stderr",
103
+ content: `[devin] skill resolver: SKILL.md for @skills:${commandName} exceeds ${MAX_SKILL_CHARS} chars (${skillContent.length}), truncating\n`,
104
+ });
105
+ skillContent = skillContent.slice(0, MAX_SKILL_CHARS);
106
+ }
107
+
108
+ // Assemble the user-request body: trailing args from the @skills: line (if any),
109
+ // plus any subsequent lines from the original prompt.
110
+ const userRequestBody = trailingArgs && rest ? `${trailingArgs}\n${rest}` : trailingArgs || rest;
111
+
112
+ return `${skillContent}\n\n---\n\nUser request: ${userRequestBody}`;
113
+ }
@@ -5,10 +5,13 @@ export type {
5
5
  ProviderResult,
6
6
  ProviderSession,
7
7
  ProviderSessionConfig,
8
+ ProviderTraits,
8
9
  } from "./types";
9
10
 
10
11
  import { ClaudeAdapter } from "./claude-adapter";
12
+ import { ClaudeManagedAdapter } from "./claude-managed-adapter";
11
13
  import { CodexAdapter } from "./codex-adapter";
14
+ import { DevinAdapter } from "./devin-adapter";
12
15
  import { PiMonoAdapter } from "./pi-mono-adapter";
13
16
  import type { ProviderAdapter } from "./types";
14
17
 
@@ -21,7 +24,13 @@ export function createProviderAdapter(provider: string): ProviderAdapter {
21
24
  return new PiMonoAdapter();
22
25
  case "codex":
23
26
  return new CodexAdapter();
27
+ case "claude-managed":
28
+ return new ClaudeManagedAdapter();
29
+ case "devin":
30
+ return new DevinAdapter();
24
31
  default:
25
- throw new Error(`Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex`);
32
+ throw new Error(
33
+ `Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, devin, claude-managed`,
34
+ );
26
35
  }
27
36
  }
@@ -155,7 +155,7 @@ class PiMonoSession implements ProviderSession {
155
155
  this._sessionId = agentSession.sessionId;
156
156
 
157
157
  // Emit session_init immediately
158
- this.emit({ type: "session_init", sessionId: this._sessionId });
158
+ this.emit({ type: "session_init", sessionId: this._sessionId, provider: "pi" });
159
159
 
160
160
  // Subscribe to agent events and normalize
161
161
  this.agentSession.subscribe((event) => this.handleAgentEvent(event));
@@ -364,6 +364,7 @@ class PiMonoSession implements ProviderSession {
364
364
  numTurns: stats.userMessages + stats.assistantMessages,
365
365
  model: this.agentSession.model?.name ?? this.config.model,
366
366
  isError: false,
367
+ provider: "pi",
367
368
  };
368
369
  }
369
370
 
@@ -391,6 +392,7 @@ class PiMonoSession implements ProviderSession {
391
392
 
392
393
  export class PiMonoAdapter implements ProviderAdapter {
393
394
  readonly name = "pi";
395
+ readonly traits = { hasMcp: true, hasLocalEnvironment: true };
394
396
  private lastCwd = ".";
395
397
 
396
398
  async createSession(config: ProviderSessionConfig): Promise<ProviderSession> {