@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.
- package/README.md +3 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +339 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devin provider adapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Devin v3 REST API to implement the `ProviderAdapter` /
|
|
5
|
+
* `ProviderSession` contract. Unlike Claude and Codex, Devin sessions are
|
|
6
|
+
* fully remote — there is no local child process. We poll the session status
|
|
7
|
+
* endpoint to drive the event stream and detect terminal states.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1 — factory wiring, polling loop, status-to-event mapping, cost
|
|
10
|
+
* tracking, playbook resolution, approval flow, structured output & PR
|
|
11
|
+
* tracking.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createSession,
|
|
16
|
+
type DevinSessionResponse,
|
|
17
|
+
type DevinSessionStatus,
|
|
18
|
+
type DevinStatusDetail,
|
|
19
|
+
getSession,
|
|
20
|
+
getSessionMessages,
|
|
21
|
+
sendMessage,
|
|
22
|
+
} from "./devin-api";
|
|
23
|
+
import { getOrCreatePlaybook } from "./devin-playbooks";
|
|
24
|
+
import { resolveDevinPrompt } from "./devin-skill-resolver";
|
|
25
|
+
import type {
|
|
26
|
+
CostData,
|
|
27
|
+
ProviderAdapter,
|
|
28
|
+
ProviderEvent,
|
|
29
|
+
ProviderResult,
|
|
30
|
+
ProviderSession,
|
|
31
|
+
ProviderSessionConfig,
|
|
32
|
+
ProviderTraits,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
/** Default polling interval in milliseconds. */
|
|
36
|
+
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
37
|
+
|
|
38
|
+
/** USD cost per ACU — configurable via env var. */
|
|
39
|
+
const DEFAULT_ACU_COST_USD = 2.25;
|
|
40
|
+
|
|
41
|
+
/** Give up after this many consecutive poll failures. */
|
|
42
|
+
const MAX_CONSECUTIVE_POLL_ERRORS = 10;
|
|
43
|
+
|
|
44
|
+
/** Max time to wait for a human approval response before giving up. */
|
|
45
|
+
const APPROVAL_TIMEOUT_MS = 60 * 60 * 1_000; // 1 hour
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Structured output schema sent with every Devin session.
|
|
49
|
+
*
|
|
50
|
+
* Devin treats this as a "notepad" it fills as it works. The `status` field
|
|
51
|
+
* lets us detect completion even when Devin stays in `waiting_for_user`
|
|
52
|
+
* instead of transitioning to `finished`. The adapter checks for
|
|
53
|
+
* `status === "done"` in the `waiting_for_user` handler.
|
|
54
|
+
*/
|
|
55
|
+
const DEVIN_STRUCTURED_OUTPUT_SCHEMA = {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
status: {
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: ["working", "done", "needs_input", "error"],
|
|
61
|
+
description:
|
|
62
|
+
"Set to 'done' when the task is fully complete, 'needs_input' when you need clarification, 'error' if the task cannot be completed.",
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "The final output or result of the task.",
|
|
67
|
+
},
|
|
68
|
+
summary: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "A brief summary of what was accomplished.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["status"],
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// DevinSession
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
class DevinSession implements ProviderSession {
|
|
81
|
+
private readonly config: ProviderSessionConfig;
|
|
82
|
+
private readonly orgId: string;
|
|
83
|
+
private readonly devinApiKey: string;
|
|
84
|
+
private readonly pollIntervalMs: number;
|
|
85
|
+
private readonly acuCostUsd: number;
|
|
86
|
+
private readonly maxAcuLimit: number | undefined;
|
|
87
|
+
|
|
88
|
+
private readonly listeners: Array<(event: ProviderEvent) => void> = [];
|
|
89
|
+
private readonly eventQueue: ProviderEvent[] = [];
|
|
90
|
+
private readonly logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
|
|
91
|
+
private readonly startTime = Date.now();
|
|
92
|
+
private readonly completionPromise: Promise<ProviderResult>;
|
|
93
|
+
private resolveCompletion!: (result: ProviderResult) => void;
|
|
94
|
+
|
|
95
|
+
private _sessionId: string | undefined;
|
|
96
|
+
private sessionUrl: string | undefined;
|
|
97
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
98
|
+
private pollCount = 0;
|
|
99
|
+
private aborted = false;
|
|
100
|
+
private settled = false;
|
|
101
|
+
|
|
102
|
+
// State tracking for change detection across polls.
|
|
103
|
+
private lastStatus: DevinSessionStatus | undefined;
|
|
104
|
+
private lastStatusDetail: DevinStatusDetail | undefined;
|
|
105
|
+
private lastStructuredOutput: string | undefined;
|
|
106
|
+
private seenPrUrls = new Set<string>();
|
|
107
|
+
private seenMessageIds = new Set<string>();
|
|
108
|
+
private approvalRequested = false;
|
|
109
|
+
private consecutivePollErrors = 0;
|
|
110
|
+
private humanResponseTimer: ReturnType<typeof setInterval> | null = null;
|
|
111
|
+
private messageCursor: string | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
config: ProviderSessionConfig,
|
|
115
|
+
orgId: string,
|
|
116
|
+
devinApiKey: string,
|
|
117
|
+
sessionResponse: DevinSessionResponse,
|
|
118
|
+
maxAcuLimit?: number,
|
|
119
|
+
) {
|
|
120
|
+
this.config = config;
|
|
121
|
+
this.orgId = orgId;
|
|
122
|
+
this.devinApiKey = devinApiKey;
|
|
123
|
+
this.pollIntervalMs = Number(process.env.DEVIN_POLL_INTERVAL_MS) || DEFAULT_POLL_INTERVAL_MS;
|
|
124
|
+
this.acuCostUsd = Number(process.env.DEVIN_ACU_COST_USD) || DEFAULT_ACU_COST_USD;
|
|
125
|
+
this.maxAcuLimit = maxAcuLimit;
|
|
126
|
+
|
|
127
|
+
this._sessionId = sessionResponse.session_id;
|
|
128
|
+
this.sessionUrl = sessionResponse.url;
|
|
129
|
+
this.logFileHandle = Bun.file(config.logFile).writer();
|
|
130
|
+
|
|
131
|
+
this.completionPromise = new Promise<ProviderResult>((resolve) => {
|
|
132
|
+
this.resolveCompletion = resolve;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Emit initial session_init event.
|
|
136
|
+
this.emit({
|
|
137
|
+
type: "session_init",
|
|
138
|
+
sessionId: sessionResponse.session_id,
|
|
139
|
+
provider: "devin",
|
|
140
|
+
providerMeta: {
|
|
141
|
+
sessionUrl: sessionResponse.url,
|
|
142
|
+
...(this.maxAcuLimit != null ? { maxAcuLimit: this.maxAcuLimit } : {}),
|
|
143
|
+
acuCostUsd: this.acuCostUsd,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
this.emit({
|
|
147
|
+
type: "message",
|
|
148
|
+
role: "assistant",
|
|
149
|
+
content: `Devin session created: ${sessionResponse.url}`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Record initial state.
|
|
153
|
+
this.lastStatus = sessionResponse.status;
|
|
154
|
+
this.lastStatusDetail = sessionResponse.status_detail;
|
|
155
|
+
|
|
156
|
+
// Start the polling loop.
|
|
157
|
+
this.startPolling();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get sessionId(): string | undefined {
|
|
161
|
+
return this._sessionId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onEvent(listener: (event: ProviderEvent) => void): void {
|
|
165
|
+
this.listeners.push(listener);
|
|
166
|
+
// Flush queued events to the new listener.
|
|
167
|
+
for (const event of this.eventQueue) {
|
|
168
|
+
listener(event);
|
|
169
|
+
}
|
|
170
|
+
this.eventQueue.length = 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async waitForCompletion(): Promise<ProviderResult> {
|
|
174
|
+
return this.completionPromise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async abort(): Promise<void> {
|
|
178
|
+
this.aborted = true;
|
|
179
|
+
this.stopPolling();
|
|
180
|
+
// Deliberately do NOT archive the Devin session here. The session remains
|
|
181
|
+
// alive in Cognition's cloud so `canResume()` can return true and the
|
|
182
|
+
// runner can retry later via `sendMessage()`. Archiving is a hard kill
|
|
183
|
+
// with no undo — only do that via an explicit API call if needed.
|
|
184
|
+
if (!this.settled) {
|
|
185
|
+
const cost = this.buildCostData(0, true);
|
|
186
|
+
this.emit({ type: "result", cost, isError: true, errorCategory: "cancelled" });
|
|
187
|
+
this.settle({
|
|
188
|
+
exitCode: 130,
|
|
189
|
+
sessionId: this._sessionId,
|
|
190
|
+
cost,
|
|
191
|
+
isError: true,
|
|
192
|
+
failureReason: "cancelled",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
// Event infrastructure (mirrors codex-adapter)
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
private emit(event: ProviderEvent): void {
|
|
202
|
+
try {
|
|
203
|
+
this.logFileHandle.write(
|
|
204
|
+
`${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`,
|
|
205
|
+
);
|
|
206
|
+
} catch {
|
|
207
|
+
// Log writer failure must not break the event stream.
|
|
208
|
+
}
|
|
209
|
+
if (this.listeners.length > 0) {
|
|
210
|
+
for (const listener of this.listeners) {
|
|
211
|
+
try {
|
|
212
|
+
listener(event);
|
|
213
|
+
} catch {
|
|
214
|
+
// Swallow listener errors.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
this.eventQueue.push(event);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private settle(result: ProviderResult): void {
|
|
223
|
+
if (this.settled) return;
|
|
224
|
+
this.settled = true;
|
|
225
|
+
this.stopPolling();
|
|
226
|
+
try {
|
|
227
|
+
const flushed = this.logFileHandle.flush();
|
|
228
|
+
(flushed instanceof Promise ? flushed : Promise.resolve(flushed))
|
|
229
|
+
.then(() => this.logFileHandle.end())
|
|
230
|
+
.catch(() => {});
|
|
231
|
+
} catch {
|
|
232
|
+
// Ignore log writer cleanup failures.
|
|
233
|
+
}
|
|
234
|
+
this.resolveCompletion(result);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// -------------------------------------------------------------------------
|
|
238
|
+
// Polling loop
|
|
239
|
+
// -------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
private startPolling(): void {
|
|
242
|
+
// Do an immediate first poll, then set up the interval.
|
|
243
|
+
void this.poll();
|
|
244
|
+
this.pollTimer = setInterval(() => {
|
|
245
|
+
void this.poll();
|
|
246
|
+
}, this.pollIntervalMs);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private stopPolling(): void {
|
|
250
|
+
if (this.pollTimer) {
|
|
251
|
+
clearInterval(this.pollTimer);
|
|
252
|
+
this.pollTimer = null;
|
|
253
|
+
}
|
|
254
|
+
if (this.humanResponseTimer) {
|
|
255
|
+
clearInterval(this.humanResponseTimer);
|
|
256
|
+
this.humanResponseTimer = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async poll(): Promise<void> {
|
|
261
|
+
if (this.settled || this.aborted) return;
|
|
262
|
+
this.pollCount += 1;
|
|
263
|
+
|
|
264
|
+
let response: DevinSessionResponse;
|
|
265
|
+
try {
|
|
266
|
+
response = await getSession(this.orgId, this.devinApiKey, this._sessionId!);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
this.consecutivePollErrors += 1;
|
|
269
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
270
|
+
this.emit({
|
|
271
|
+
type: "raw_stderr",
|
|
272
|
+
content: `[devin] Poll error (${this.consecutivePollErrors}/${MAX_CONSECUTIVE_POLL_ERRORS}): ${message}\n`,
|
|
273
|
+
});
|
|
274
|
+
if (this.consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
|
|
275
|
+
const reason = `Devin polling abandoned after ${MAX_CONSECUTIVE_POLL_ERRORS} consecutive errors. Last: ${message}`;
|
|
276
|
+
this.emit({ type: "error", message: reason });
|
|
277
|
+
const cost = this.buildCostData(0, true);
|
|
278
|
+
this.emit({ type: "result", cost, isError: true, errorCategory: "poll_failure" });
|
|
279
|
+
this.settle({
|
|
280
|
+
exitCode: 1,
|
|
281
|
+
sessionId: this._sessionId,
|
|
282
|
+
cost,
|
|
283
|
+
isError: true,
|
|
284
|
+
failureReason: reason,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Reset on successful poll.
|
|
290
|
+
this.consecutivePollErrors = 0;
|
|
291
|
+
|
|
292
|
+
// Log raw poll data to local JSONL file for debugging, but don't emit
|
|
293
|
+
// as raw_log — the session log viewer can't parse the Devin API shape
|
|
294
|
+
// and silently drops it. Conversation messages are emitted separately
|
|
295
|
+
// in pollMessages() in a format the viewer understands.
|
|
296
|
+
try {
|
|
297
|
+
this.logFileHandle.write(
|
|
298
|
+
`${JSON.stringify({ type: "raw_log", content: JSON.stringify(response), timestamp: new Date().toISOString() })}\n`,
|
|
299
|
+
);
|
|
300
|
+
} catch {
|
|
301
|
+
// Log writer failure must not break the event stream.
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Track structured output changes.
|
|
305
|
+
const currentStructuredOutput = response.structured_output
|
|
306
|
+
? JSON.stringify(response.structured_output)
|
|
307
|
+
: undefined;
|
|
308
|
+
if (currentStructuredOutput && currentStructuredOutput !== this.lastStructuredOutput) {
|
|
309
|
+
this.lastStructuredOutput = currentStructuredOutput;
|
|
310
|
+
this.emit({
|
|
311
|
+
type: "custom",
|
|
312
|
+
name: "devin.structured_output",
|
|
313
|
+
data: { sessionId: this._sessionId, structuredOutput: response.structured_output },
|
|
314
|
+
});
|
|
315
|
+
const so = response.structured_output as Record<string, unknown>;
|
|
316
|
+
this.emitSystemLog("structured_output", {
|
|
317
|
+
taskStatus: so.status,
|
|
318
|
+
output: so.output,
|
|
319
|
+
summary: so.summary,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Track new pull requests.
|
|
324
|
+
if (response.pull_requests) {
|
|
325
|
+
for (const pr of response.pull_requests) {
|
|
326
|
+
if (!this.seenPrUrls.has(pr.pr_url)) {
|
|
327
|
+
this.seenPrUrls.add(pr.pr_url);
|
|
328
|
+
this.emit({
|
|
329
|
+
type: "custom",
|
|
330
|
+
name: "devin.pull_request",
|
|
331
|
+
data: { sessionId: this._sessionId, prUrl: pr.pr_url, prState: pr.pr_state },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Fetch new conversation messages from Devin.
|
|
338
|
+
await this.pollMessages();
|
|
339
|
+
|
|
340
|
+
// Process status transitions.
|
|
341
|
+
const statusChanged =
|
|
342
|
+
response.status !== this.lastStatus || response.status_detail !== this.lastStatusDetail;
|
|
343
|
+
this.lastStatus = response.status;
|
|
344
|
+
this.lastStatusDetail = response.status_detail;
|
|
345
|
+
|
|
346
|
+
this.processStatus(response, statusChanged);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// -------------------------------------------------------------------------
|
|
350
|
+
// Conversation messages
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
private async pollMessages(): Promise<void> {
|
|
354
|
+
try {
|
|
355
|
+
const resp = await getSessionMessages(
|
|
356
|
+
this.orgId,
|
|
357
|
+
this.devinApiKey,
|
|
358
|
+
this._sessionId!,
|
|
359
|
+
this.messageCursor,
|
|
360
|
+
);
|
|
361
|
+
if (resp.end_cursor) {
|
|
362
|
+
this.messageCursor = resp.end_cursor;
|
|
363
|
+
}
|
|
364
|
+
for (const msg of resp.items) {
|
|
365
|
+
if (this.seenMessageIds.has(msg.event_id)) continue;
|
|
366
|
+
this.seenMessageIds.add(msg.event_id);
|
|
367
|
+
const role = msg.source === "devin" ? "assistant" : "user";
|
|
368
|
+
this.emit({
|
|
369
|
+
type: "raw_log",
|
|
370
|
+
content: JSON.stringify({
|
|
371
|
+
type: role,
|
|
372
|
+
message: { role, content: msg.message },
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
this.emit({ type: "message", role, content: msg.message });
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
// Non-fatal — messages are supplementary to status polling.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// -------------------------------------------------------------------------
|
|
383
|
+
// Status-to-event mapping
|
|
384
|
+
// -------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
private processStatus(response: DevinSessionResponse, statusChanged: boolean): void {
|
|
387
|
+
const { status } = response;
|
|
388
|
+
|
|
389
|
+
switch (status) {
|
|
390
|
+
case "new":
|
|
391
|
+
case "creating":
|
|
392
|
+
case "claimed":
|
|
393
|
+
case "resuming": {
|
|
394
|
+
if (statusChanged) {
|
|
395
|
+
this.emit({ type: "progress", message: status });
|
|
396
|
+
this.emitSystemLog("status", { status, statusDetail: status });
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case "running": {
|
|
402
|
+
this.processRunningStatus(response, statusChanged);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case "exit": {
|
|
407
|
+
this.handleTerminalSuccess(response);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
case "error": {
|
|
412
|
+
this.handleTerminalError(response);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case "suspended": {
|
|
417
|
+
this.handleSuspended(response);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private processRunningStatus(response: DevinSessionResponse, statusChanged: boolean): void {
|
|
424
|
+
const detail = response.status_detail;
|
|
425
|
+
|
|
426
|
+
// Check structured output completion before examining status_detail.
|
|
427
|
+
// Devin may set structured output `status: "done"` while still in any
|
|
428
|
+
// running sub-state (working, waiting_for_user, etc.) — the structured
|
|
429
|
+
// output is the authoritative completion signal.
|
|
430
|
+
if (this.isStructuredOutputDone(response)) {
|
|
431
|
+
this.handleTerminalSuccess(response);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
switch (detail) {
|
|
436
|
+
case "working": {
|
|
437
|
+
if (statusChanged) {
|
|
438
|
+
this.emit({ type: "progress", message: "working" });
|
|
439
|
+
this.emitSystemLog("status", { status: "running", statusDetail: "working" });
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
case "waiting_for_user": {
|
|
445
|
+
if (statusChanged) {
|
|
446
|
+
this.emit({ type: "progress", message: "waiting for user" });
|
|
447
|
+
this.emitSystemLog("status", {
|
|
448
|
+
status: "running",
|
|
449
|
+
statusDetail: "waiting_for_user",
|
|
450
|
+
});
|
|
451
|
+
this.emit({
|
|
452
|
+
type: "message",
|
|
453
|
+
role: "assistant",
|
|
454
|
+
content: `Devin is waiting for user input. Session: ${this.sessionUrl}`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case "waiting_for_approval": {
|
|
461
|
+
if (statusChanged) {
|
|
462
|
+
this.emit({ type: "progress", message: "waiting for approval" });
|
|
463
|
+
this.emitSystemLog("status", {
|
|
464
|
+
status: "running",
|
|
465
|
+
statusDetail: "waiting_for_approval",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
// Request human input via the swarm API (once per approval cycle).
|
|
469
|
+
if (!this.approvalRequested) {
|
|
470
|
+
this.approvalRequested = true;
|
|
471
|
+
void this.requestHumanApproval();
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case "finished": {
|
|
477
|
+
this.handleTerminalSuccess(response);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
default: {
|
|
482
|
+
if (statusChanged) {
|
|
483
|
+
const label = detail ?? "unknown";
|
|
484
|
+
this.emit({ type: "progress", message: label });
|
|
485
|
+
this.emitSystemLog("status", { status: "running", statusDetail: label });
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private handleTerminalSuccess(response: DevinSessionResponse): void {
|
|
493
|
+
const acusConsumed = response.acus_consumed ?? 0;
|
|
494
|
+
const output = this.formatStructuredOutput();
|
|
495
|
+
const cost = this.buildCostData(acusConsumed, false);
|
|
496
|
+
|
|
497
|
+
this.emit({ type: "progress", message: "completed" });
|
|
498
|
+
this.emitSystemLog("status", {
|
|
499
|
+
status: "completed",
|
|
500
|
+
acusConsumed,
|
|
501
|
+
sessionUrl: this.sessionUrl,
|
|
502
|
+
});
|
|
503
|
+
this.emit({
|
|
504
|
+
type: "message",
|
|
505
|
+
role: "assistant",
|
|
506
|
+
content: `Devin session completed successfully. ACUs consumed: ${acusConsumed}. Session: ${this.sessionUrl}`,
|
|
507
|
+
});
|
|
508
|
+
this.emit({ type: "result", cost, output, isError: false });
|
|
509
|
+
this.settle({
|
|
510
|
+
exitCode: 0,
|
|
511
|
+
sessionId: this._sessionId,
|
|
512
|
+
cost,
|
|
513
|
+
output,
|
|
514
|
+
isError: false,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private handleTerminalError(response: DevinSessionResponse): void {
|
|
519
|
+
const acusConsumed = response.acus_consumed ?? 0;
|
|
520
|
+
const cost = this.buildCostData(acusConsumed, true);
|
|
521
|
+
const message = `Devin session ended with error. ACUs consumed: ${acusConsumed}. Session: ${this.sessionUrl}`;
|
|
522
|
+
|
|
523
|
+
this.emitSystemLog("status", {
|
|
524
|
+
status: "error",
|
|
525
|
+
acusConsumed,
|
|
526
|
+
sessionUrl: this.sessionUrl,
|
|
527
|
+
});
|
|
528
|
+
this.emit({ type: "error", message });
|
|
529
|
+
this.emit({ type: "result", cost, isError: true, errorCategory: "devin_error" });
|
|
530
|
+
this.settle({
|
|
531
|
+
exitCode: 1,
|
|
532
|
+
sessionId: this._sessionId,
|
|
533
|
+
cost,
|
|
534
|
+
isError: true,
|
|
535
|
+
failureReason: message,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private handleSuspended(response: DevinSessionResponse): void {
|
|
540
|
+
const acusConsumed = response.acus_consumed ?? 0;
|
|
541
|
+
const detail = response.status_detail;
|
|
542
|
+
const cost = this.buildCostData(acusConsumed, true);
|
|
543
|
+
|
|
544
|
+
const categoryMap: Record<string, string> = {
|
|
545
|
+
inactivity: "suspended_inactivity",
|
|
546
|
+
user_request: "suspended_user",
|
|
547
|
+
usage_limit_exceeded: "suspended_cost",
|
|
548
|
+
out_of_credits: "suspended_cost",
|
|
549
|
+
out_of_quota: "suspended_cost",
|
|
550
|
+
no_quota_allocation: "suspended_cost",
|
|
551
|
+
payment_declined: "suspended_cost",
|
|
552
|
+
org_usage_limit_exceeded: "suspended_cost",
|
|
553
|
+
error: "suspended_cost",
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const errorCategory = categoryMap[detail ?? ""] ?? "suspended";
|
|
557
|
+
const reason = `Devin session suspended${detail ? `: ${detail.replaceAll("_", " ")}` : ""}`;
|
|
558
|
+
|
|
559
|
+
if (detail === "inactivity") {
|
|
560
|
+
this.emit({
|
|
561
|
+
type: "message",
|
|
562
|
+
role: "assistant",
|
|
563
|
+
content: `Devin session suspended due to inactivity. Session: ${this.sessionUrl}`,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (errorCategory === "suspended_cost" || errorCategory === "suspended") {
|
|
568
|
+
this.emit({ type: "error", message: reason });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
this.emit({ type: "result", cost, isError: true, errorCategory });
|
|
572
|
+
this.settle({
|
|
573
|
+
exitCode: 1,
|
|
574
|
+
sessionId: this._sessionId,
|
|
575
|
+
cost,
|
|
576
|
+
isError: true,
|
|
577
|
+
errorCategory,
|
|
578
|
+
failureReason: reason,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// Approval flow
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
private async requestHumanApproval(): Promise<void> {
|
|
587
|
+
if (!this.config.apiUrl || !this.config.apiKey || !this.config.taskId) return;
|
|
588
|
+
|
|
589
|
+
// Why a direct API call instead of an emit? The runner's event listener
|
|
590
|
+
// handles ProviderEvents generically (progress, cost) but has no built-in
|
|
591
|
+
// handler that creates human-input requests from events. Claude/Codex
|
|
592
|
+
// trigger this via their MCP tool (`request-human-input`), which calls
|
|
593
|
+
// the same API endpoint under the hood. Since Devin has no MCP, we call
|
|
594
|
+
// the API directly — it's what stores the request in the DB and triggers
|
|
595
|
+
// Slack routing.
|
|
596
|
+
try {
|
|
597
|
+
const res = await fetch(`${this.config.apiUrl}/api/tasks/${this.config.taskId}/human-input`, {
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: {
|
|
600
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
601
|
+
"Content-Type": "application/json",
|
|
602
|
+
"X-Agent-ID": this.config.agentId,
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify({
|
|
605
|
+
question: `Devin is waiting for approval. Please review and respond. Session: ${this.sessionUrl}`,
|
|
606
|
+
}),
|
|
607
|
+
});
|
|
608
|
+
if (!res.ok) {
|
|
609
|
+
this.emit({
|
|
610
|
+
type: "raw_stderr",
|
|
611
|
+
content: `[devin] Failed to request human approval: HTTP ${res.status}\n`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
this.emit({
|
|
617
|
+
type: "raw_stderr",
|
|
618
|
+
content: `[devin] Failed to request human approval: ${message}\n`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Poll for the human response and relay it to Devin.
|
|
623
|
+
void this.pollForHumanResponse();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async pollForHumanResponse(): Promise<void> {
|
|
627
|
+
if (!this.config.apiUrl || !this.config.apiKey || !this.config.taskId) return;
|
|
628
|
+
|
|
629
|
+
// Clear any previous human-response timer before starting a new one.
|
|
630
|
+
if (this.humanResponseTimer) clearInterval(this.humanResponseTimer);
|
|
631
|
+
|
|
632
|
+
const approvalStart = Date.now();
|
|
633
|
+
|
|
634
|
+
// Simple polling loop — check every poll interval for a human response.
|
|
635
|
+
this.humanResponseTimer = setInterval(async () => {
|
|
636
|
+
if (this.settled || this.aborted) {
|
|
637
|
+
if (this.humanResponseTimer) {
|
|
638
|
+
clearInterval(this.humanResponseTimer);
|
|
639
|
+
this.humanResponseTimer = null;
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Give up after APPROVAL_TIMEOUT_MS to avoid leaking timers on
|
|
645
|
+
// abandoned approval flows. Devin's own inactivity timeout will
|
|
646
|
+
// eventually suspend the session, which the main poll loop handles.
|
|
647
|
+
if (Date.now() - approvalStart > APPROVAL_TIMEOUT_MS) {
|
|
648
|
+
this.emit({
|
|
649
|
+
type: "raw_stderr",
|
|
650
|
+
content: `[devin] Approval polling timed out after ${APPROVAL_TIMEOUT_MS / 60_000} minutes\n`,
|
|
651
|
+
});
|
|
652
|
+
if (this.humanResponseTimer) {
|
|
653
|
+
clearInterval(this.humanResponseTimer);
|
|
654
|
+
this.humanResponseTimer = null;
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const res = await fetch(
|
|
661
|
+
`${this.config.apiUrl}/api/tasks/${this.config.taskId}/human-input`,
|
|
662
|
+
{
|
|
663
|
+
method: "GET",
|
|
664
|
+
headers: {
|
|
665
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
666
|
+
"X-Agent-ID": this.config.agentId,
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
);
|
|
670
|
+
if (res.ok) {
|
|
671
|
+
const data = (await res.json()) as { response?: string; answered?: boolean };
|
|
672
|
+
if (data.answered && data.response) {
|
|
673
|
+
if (this.humanResponseTimer) {
|
|
674
|
+
clearInterval(this.humanResponseTimer);
|
|
675
|
+
this.humanResponseTimer = null;
|
|
676
|
+
}
|
|
677
|
+
this.approvalRequested = false;
|
|
678
|
+
// Relay the human response to Devin.
|
|
679
|
+
try {
|
|
680
|
+
await sendMessage(this.orgId, this.devinApiKey, this._sessionId!, data.response);
|
|
681
|
+
this.emit({
|
|
682
|
+
type: "message",
|
|
683
|
+
role: "user",
|
|
684
|
+
content: `Human response relayed to Devin: ${data.response}`,
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
688
|
+
this.emit({
|
|
689
|
+
type: "raw_stderr",
|
|
690
|
+
content: `[devin] Failed to relay human response: ${message}\n`,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} catch {
|
|
696
|
+
// Transient failure — keep trying.
|
|
697
|
+
}
|
|
698
|
+
}, this.pollIntervalMs);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// -------------------------------------------------------------------------
|
|
702
|
+
// Structured output completion detection
|
|
703
|
+
// -------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Check if the structured output signals task completion.
|
|
707
|
+
* Returns true when the structured output has `status: "done"`.
|
|
708
|
+
*/
|
|
709
|
+
private isStructuredOutputDone(response: DevinSessionResponse): boolean {
|
|
710
|
+
const output = response.structured_output;
|
|
711
|
+
if (!output || typeof output !== "object") return false;
|
|
712
|
+
return (output as Record<string, unknown>).status === "done";
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Extract human-readable text from the last structured output.
|
|
717
|
+
* Returns summary + output joined as plain text, or the raw JSON
|
|
718
|
+
* string if extraction fails.
|
|
719
|
+
*/
|
|
720
|
+
private formatStructuredOutput(): string | undefined {
|
|
721
|
+
if (!this.lastStructuredOutput) return undefined;
|
|
722
|
+
try {
|
|
723
|
+
const parsed = JSON.parse(this.lastStructuredOutput);
|
|
724
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
725
|
+
const parts: string[] = [];
|
|
726
|
+
if (parsed.summary) parts.push(parsed.summary);
|
|
727
|
+
if (parsed.output) parts.push(parsed.output);
|
|
728
|
+
if (parts.length > 0) return parts.join("\n\n");
|
|
729
|
+
}
|
|
730
|
+
} catch {
|
|
731
|
+
// Fall through to raw.
|
|
732
|
+
}
|
|
733
|
+
return this.lastStructuredOutput;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// -------------------------------------------------------------------------
|
|
737
|
+
// Session log helpers
|
|
738
|
+
// -------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Emit a system-role raw_log entry that the session log viewer can parse.
|
|
742
|
+
* Used for status transitions and structured output — these render as
|
|
743
|
+
* system messages with a `provider_meta` payload so the viewer can add
|
|
744
|
+
* pills/colors.
|
|
745
|
+
*/
|
|
746
|
+
private emitSystemLog(kind: "status" | "structured_output", data: Record<string, unknown>): void {
|
|
747
|
+
this.emit({
|
|
748
|
+
type: "raw_log",
|
|
749
|
+
content: JSON.stringify({
|
|
750
|
+
type: "system",
|
|
751
|
+
message: { role: "system", content: "" },
|
|
752
|
+
provider_meta: { provider: "devin", kind, ...data },
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// -------------------------------------------------------------------------
|
|
758
|
+
// Cost tracking
|
|
759
|
+
// -------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
private buildCostData(acusConsumed: number, isError: boolean): CostData {
|
|
762
|
+
return {
|
|
763
|
+
sessionId: this._sessionId ?? "",
|
|
764
|
+
taskId: this.config.taskId,
|
|
765
|
+
agentId: this.config.agentId,
|
|
766
|
+
totalCostUsd: acusConsumed * this.acuCostUsd,
|
|
767
|
+
inputTokens: 0,
|
|
768
|
+
outputTokens: 0,
|
|
769
|
+
durationMs: Date.now() - this.startTime,
|
|
770
|
+
numTurns: this.pollCount,
|
|
771
|
+
model: "devin",
|
|
772
|
+
isError,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// DevinAdapter
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
export class DevinAdapter implements ProviderAdapter {
|
|
782
|
+
readonly name = "devin";
|
|
783
|
+
/** Cached from the most recent createSession() for canResume() fallback. */
|
|
784
|
+
private lastApiKey?: string;
|
|
785
|
+
private lastOrgId?: string;
|
|
786
|
+
get traits(): ProviderTraits {
|
|
787
|
+
const hasMcp = (process.env.HAS_MCP ?? "").toLowerCase() === "true";
|
|
788
|
+
return { hasMcp, hasLocalEnvironment: false };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async createSession(config: ProviderSessionConfig): Promise<ProviderSession> {
|
|
792
|
+
// Resolve credentials from config.env (injected by runner) or process.env.
|
|
793
|
+
const env = config.env ?? {};
|
|
794
|
+
const devinApiKey = env.DEVIN_API_KEY ?? process.env.DEVIN_API_KEY;
|
|
795
|
+
const orgId = env.DEVIN_ORG_ID ?? process.env.DEVIN_ORG_ID;
|
|
796
|
+
|
|
797
|
+
if (!devinApiKey) {
|
|
798
|
+
throw new Error("[devin] DEVIN_API_KEY is required. Set it in environment or agent config.");
|
|
799
|
+
}
|
|
800
|
+
if (!orgId) {
|
|
801
|
+
throw new Error("[devin] DEVIN_ORG_ID is required. Set it in environment or agent config.");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Cache for canResume() which only receives a sessionId.
|
|
805
|
+
this.lastApiKey = devinApiKey;
|
|
806
|
+
this.lastOrgId = orgId;
|
|
807
|
+
|
|
808
|
+
const hasMcp = (env.HAS_MCP ?? process.env.HAS_MCP ?? "").toLowerCase() === "true";
|
|
809
|
+
if (hasMcp) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
"[devin] HAS_MCP=true is not supported yet — Devin MCP integration has not been tested.",
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// NOTE: is there a better place to handle this logic?
|
|
816
|
+
if (config.role === "lead" && !hasMcp) {
|
|
817
|
+
// Probably cannot happen as the envs from devin and the lead live in different files, but jsut in case
|
|
818
|
+
throw new Error(
|
|
819
|
+
"[devin] Devin is configured as lead but HAS_MCP=false. A lead needs access to the MCP to function. ",
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// If there's a system prompt, resolve it to a playbook.
|
|
824
|
+
let playbookId: string | undefined;
|
|
825
|
+
if (config.systemPrompt) {
|
|
826
|
+
try {
|
|
827
|
+
playbookId = await getOrCreatePlaybook(
|
|
828
|
+
orgId,
|
|
829
|
+
devinApiKey,
|
|
830
|
+
`swarm-${config.taskId ?? "session"}`,
|
|
831
|
+
// systemPrompt is per-agent (not per-task). The runner composes it
|
|
832
|
+
// from the agent's template + role config. It's stable across tasks
|
|
833
|
+
// for the same agent, so the playbook cache effectively deduplicates
|
|
834
|
+
// — one playbook per agent configuration, reused across tasks.
|
|
835
|
+
config.systemPrompt,
|
|
836
|
+
config.apiUrl,
|
|
837
|
+
config.apiKey,
|
|
838
|
+
);
|
|
839
|
+
} catch (err) {
|
|
840
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
841
|
+
// Non-fatal — log and continue without playbook.
|
|
842
|
+
console.error(`[devin] Failed to create playbook: ${message}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Build repos array from the task's vcsRepo (e.g. "owner/repo").
|
|
847
|
+
const repos: string[] = [];
|
|
848
|
+
if (config.vcsRepo) {
|
|
849
|
+
repos.push(config.vcsRepo);
|
|
850
|
+
}
|
|
851
|
+
// Inline skill content if prompt starts with @skills:<name>.
|
|
852
|
+
const resolvedPrompt = await resolveDevinPrompt(config.prompt);
|
|
853
|
+
|
|
854
|
+
// Resolve max ACU limit from env.
|
|
855
|
+
const rawAcuLimit = env.DEVIN_MAX_ACU_LIMIT ?? process.env.DEVIN_MAX_ACU_LIMIT;
|
|
856
|
+
const maxAcuLimit = rawAcuLimit ? Number(rawAcuLimit) : undefined;
|
|
857
|
+
|
|
858
|
+
// Create the Devin session.
|
|
859
|
+
const sessionResponse = await createSession(orgId, devinApiKey, {
|
|
860
|
+
prompt: resolvedPrompt,
|
|
861
|
+
...(playbookId ? { playbook_id: playbookId } : {}),
|
|
862
|
+
...(repos.length > 0 ? { repos } : {}),
|
|
863
|
+
...(maxAcuLimit != null ? { max_acu_limit: maxAcuLimit } : {}),
|
|
864
|
+
structured_output_schema: DEVIN_STRUCTURED_OUTPUT_SCHEMA,
|
|
865
|
+
title: `swarm-task-${config.taskId ?? "unknown"}`,
|
|
866
|
+
tags: ["agent-swarm", config.agentId],
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return new DevinSession(config, orgId, devinApiKey, sessionResponse, maxAcuLimit);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async canResume(sessionId: string): Promise<boolean> {
|
|
873
|
+
if (!sessionId || typeof sessionId !== "string") return false;
|
|
874
|
+
|
|
875
|
+
const devinApiKey = this.lastApiKey ?? process.env.DEVIN_API_KEY;
|
|
876
|
+
const orgId = this.lastOrgId ?? process.env.DEVIN_ORG_ID;
|
|
877
|
+
if (!devinApiKey || !orgId) return false;
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const response = await getSession(orgId, devinApiKey, sessionId);
|
|
881
|
+
// Devin's API may allow sending messages to some errored sessions, but
|
|
882
|
+
// not all error subtypes are recoverable. Conservative default: treat
|
|
883
|
+
// `error` as non-resumable to avoid the runner looping on a broken session.
|
|
884
|
+
// Only `suspended` sessions (inactivity, user_request, cost limits) are resumable.
|
|
885
|
+
return response.status !== "exit" && response.status !== "error";
|
|
886
|
+
} catch {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
formatCommand(commandName: string): string {
|
|
892
|
+
return `@skills:${commandName}`;
|
|
893
|
+
}
|
|
894
|
+
}
|