@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
package/src/commands/runner.ts
CHANGED
|
@@ -19,16 +19,21 @@ import {
|
|
|
19
19
|
type ProviderSessionConfig,
|
|
20
20
|
} from "../providers/index.ts";
|
|
21
21
|
import { initTelemetry, telemetry } from "../telemetry.ts";
|
|
22
|
-
import type { RepoGuidelines } from "../types.ts";
|
|
22
|
+
import type { ProviderName, RepoGuidelines } from "../types.ts";
|
|
23
|
+
import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
|
|
23
24
|
import { getContextWindowSize } from "../utils/context-window.ts";
|
|
24
25
|
import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
|
|
25
26
|
import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
|
|
26
27
|
import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
|
|
28
|
+
import { scrubSecrets } from "../utils/secret-scrubber.ts";
|
|
27
29
|
import { detectVcsProvider } from "../vcs/index.ts";
|
|
28
30
|
import { interpolate } from "../workflows/template.ts";
|
|
29
31
|
// Side-effect import: registers runner trigger/resumption templates
|
|
30
32
|
import "./templates.ts";
|
|
31
33
|
|
|
34
|
+
/** Throttle interval for progress updates (3 seconds). */
|
|
35
|
+
const PROGRESS_THROTTLE_MS = 3000;
|
|
36
|
+
|
|
32
37
|
/** Save PM2 process list for persistence across container restarts */
|
|
33
38
|
async function savePm2State(role: string): Promise<void> {
|
|
34
39
|
try {
|
|
@@ -527,6 +532,7 @@ export async function ensureTaskFinished(
|
|
|
527
532
|
taskId: string,
|
|
528
533
|
exitCode: number,
|
|
529
534
|
failureReason?: string,
|
|
535
|
+
providerOutput?: string,
|
|
530
536
|
): Promise<void> {
|
|
531
537
|
const headers: Record<string, string> = {
|
|
532
538
|
"X-Agent-ID": config.agentId,
|
|
@@ -543,6 +549,9 @@ export async function ensureTaskFinished(
|
|
|
543
549
|
|
|
544
550
|
if (status === "failed") {
|
|
545
551
|
body.failureReason = failureReason || `Claude process exited with code ${exitCode}`;
|
|
552
|
+
} else if (providerOutput) {
|
|
553
|
+
// Provider already supplied structured output (e.g. Devin) — use directly.
|
|
554
|
+
body.output = providerOutput;
|
|
546
555
|
} else {
|
|
547
556
|
// Try structured output fallback if the task has an outputSchema
|
|
548
557
|
const adapterType = process.env.HARNESS_PROVIDER || "claude";
|
|
@@ -810,21 +819,28 @@ async function resumeTaskViaAPI(config: ApiConfig, taskId: string): Promise<bool
|
|
|
810
819
|
async function buildResumePrompt(
|
|
811
820
|
task: { id: string; task: string; progress?: string },
|
|
812
821
|
fmt: (cmd: string) => string = (cmd) => `/${cmd}`,
|
|
822
|
+
options?: { hasMcp?: boolean },
|
|
813
823
|
): Promise<string> {
|
|
824
|
+
const hasMcp = options?.hasMcp !== false;
|
|
825
|
+
const completionInstructions = hasMcp
|
|
826
|
+
? '\n\nWhen done, use `store-progress` with status: "completed" and include your output.'
|
|
827
|
+
: "";
|
|
814
828
|
if (task.progress) {
|
|
815
829
|
const result = await resolveTemplateAsync("task.resumption.with_progress", {
|
|
816
|
-
work_on_task_cmd: fmt("work-on-task"),
|
|
817
|
-
task_id: task.id,
|
|
830
|
+
work_on_task_cmd: hasMcp ? fmt("work-on-task") : "",
|
|
831
|
+
task_id: hasMcp ? task.id : "",
|
|
818
832
|
task_description: task.task,
|
|
819
833
|
progress: task.progress,
|
|
834
|
+
completion_instructions: completionInstructions,
|
|
820
835
|
});
|
|
821
836
|
return result.text;
|
|
822
837
|
}
|
|
823
838
|
|
|
824
839
|
const result = await resolveTemplateAsync("task.resumption.no_progress", {
|
|
825
|
-
work_on_task_cmd: fmt("work-on-task"),
|
|
826
|
-
task_id: task.id,
|
|
840
|
+
work_on_task_cmd: hasMcp ? fmt("work-on-task") : "",
|
|
841
|
+
task_id: hasMcp ? task.id : "",
|
|
827
842
|
task_description: task.task,
|
|
843
|
+
completion_instructions: completionInstructions,
|
|
828
844
|
});
|
|
829
845
|
return result.text;
|
|
830
846
|
}
|
|
@@ -1032,13 +1048,18 @@ async function saveProviderSessionId(
|
|
|
1032
1048
|
apiKey: string,
|
|
1033
1049
|
taskId: string,
|
|
1034
1050
|
claudeSessionId: string,
|
|
1051
|
+
provider?: ProviderName,
|
|
1052
|
+
providerMeta?: Record<string, unknown>,
|
|
1035
1053
|
): Promise<void> {
|
|
1036
1054
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
1037
1055
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
1056
|
+
const body: Record<string, unknown> = { claudeSessionId };
|
|
1057
|
+
if (provider !== undefined) body.provider = provider;
|
|
1058
|
+
if (providerMeta !== undefined) body.providerMeta = providerMeta;
|
|
1038
1059
|
await fetch(`${apiUrl}/api/tasks/${taskId}/claude-session`, {
|
|
1039
1060
|
method: "PUT",
|
|
1040
1061
|
headers,
|
|
1041
|
-
body: JSON.stringify(
|
|
1062
|
+
body: JSON.stringify(body),
|
|
1042
1063
|
});
|
|
1043
1064
|
}
|
|
1044
1065
|
|
|
@@ -1250,7 +1271,8 @@ interface Trigger {
|
|
|
1250
1271
|
| "task_offered"
|
|
1251
1272
|
| "unread_mentions"
|
|
1252
1273
|
| "pool_tasks_available"
|
|
1253
|
-
| "channel_activity"
|
|
1274
|
+
| "channel_activity"
|
|
1275
|
+
| "budget_refused";
|
|
1254
1276
|
taskId?: string;
|
|
1255
1277
|
task?: unknown;
|
|
1256
1278
|
mentionsCount?: number;
|
|
@@ -1275,6 +1297,16 @@ interface Trigger {
|
|
|
1275
1297
|
}>;
|
|
1276
1298
|
cursorUpdates?: Array<{ channelId: string; ts: string }>; // Deferred cursor commits for channel_activity
|
|
1277
1299
|
requestedBy?: { name: string; email?: string };
|
|
1300
|
+
// Phase 4 — budget_refused fields. The server emits this envelope from
|
|
1301
|
+
// /api/poll and MCP task-action accept when an admission gate refuses to
|
|
1302
|
+
// let the agent claim a task. Worker reads cause + reset/spend/budget for
|
|
1303
|
+
// structured logging and back-off; never reaches buildPromptForTrigger.
|
|
1304
|
+
cause?: "agent" | "global";
|
|
1305
|
+
agentSpend?: number;
|
|
1306
|
+
agentBudget?: number;
|
|
1307
|
+
globalSpend?: number;
|
|
1308
|
+
globalBudget?: number;
|
|
1309
|
+
resetAt?: string; // ISO 8601, next UTC midnight
|
|
1278
1310
|
}
|
|
1279
1311
|
|
|
1280
1312
|
/** Options for polling */
|
|
@@ -1372,7 +1404,9 @@ async function buildPromptForTrigger(
|
|
|
1372
1404
|
trigger: Trigger,
|
|
1373
1405
|
defaultPrompt: string,
|
|
1374
1406
|
fmt: (cmd: string) => string = (cmd) => `/${cmd}`,
|
|
1407
|
+
options?: { hasMcp?: boolean },
|
|
1375
1408
|
): Promise<string> {
|
|
1409
|
+
const hasMcp = options?.hasMcp !== false;
|
|
1376
1410
|
switch (trigger.type) {
|
|
1377
1411
|
case "task_assigned": {
|
|
1378
1412
|
// Use the work-on-task command with task ID and description
|
|
@@ -1382,10 +1416,13 @@ async function buildPromptForTrigger(
|
|
|
1382
1416
|
: null;
|
|
1383
1417
|
const taskDescSection = taskDesc ? `\n\nTask: "${taskDesc}"` : "";
|
|
1384
1418
|
|
|
1385
|
-
// Build output instructions — use outputSchema if present, otherwise generic
|
|
1419
|
+
// Build output instructions — use outputSchema if present, otherwise generic.
|
|
1420
|
+
// Skip store-progress references for providers without MCP (e.g. Devin).
|
|
1386
1421
|
const taskObj = trigger.task as Record<string, unknown> | undefined;
|
|
1387
1422
|
let outputInstructions: string;
|
|
1388
|
-
if (
|
|
1423
|
+
if (!hasMcp) {
|
|
1424
|
+
outputInstructions = "";
|
|
1425
|
+
} else if (taskObj?.outputSchema && typeof taskObj.outputSchema === "object") {
|
|
1389
1426
|
outputInstructions = `\n\n**Required Output Format**: When completing this task, you MUST call store-progress with output that is valid JSON conforming to this schema:\n\`\`\`json\n${JSON.stringify(taskObj.outputSchema, null, 2)}\n\`\`\`\nCall store-progress with status "completed" and your JSON output. If your output doesn't match the schema, the tool call will fail and you should fix and retry.`;
|
|
1390
1427
|
} else {
|
|
1391
1428
|
outputInstructions =
|
|
@@ -1399,8 +1436,8 @@ async function buildPromptForTrigger(
|
|
|
1399
1436
|
: "";
|
|
1400
1437
|
|
|
1401
1438
|
const result = await resolveTemplateAsync("task.trigger.assigned", {
|
|
1402
|
-
work_on_task_cmd: fmt("work-on-task"),
|
|
1403
|
-
task_id: trigger.taskId,
|
|
1439
|
+
work_on_task_cmd: hasMcp ? fmt("work-on-task") : "",
|
|
1440
|
+
task_id: hasMcp ? trigger.taskId : "",
|
|
1404
1441
|
task_desc_section: taskDescSection + requestedBySection,
|
|
1405
1442
|
output_instructions: outputInstructions,
|
|
1406
1443
|
});
|
|
@@ -1415,13 +1452,16 @@ async function buildPromptForTrigger(
|
|
|
1415
1452
|
: null;
|
|
1416
1453
|
const taskDescSection = taskDesc ? `\n\nA task has been offered to you:\n"${taskDesc}"` : "";
|
|
1417
1454
|
const result = await resolveTemplateAsync("task.trigger.offered", {
|
|
1418
|
-
review_offered_task_cmd: fmt("review-offered-task"),
|
|
1419
|
-
task_id: trigger.taskId,
|
|
1455
|
+
review_offered_task_cmd: hasMcp ? fmt("review-offered-task") : "",
|
|
1456
|
+
task_id: hasMcp ? trigger.taskId : "",
|
|
1420
1457
|
task_desc_section: taskDescSection,
|
|
1421
1458
|
});
|
|
1422
1459
|
return result.text;
|
|
1423
1460
|
}
|
|
1424
1461
|
|
|
1462
|
+
// NOTE: unread_mentions, pool_tasks_available, and channel_activity triggers
|
|
1463
|
+
// reference MCP tools (read-messages, get-tasks, task-action, slack-reply, etc.)
|
|
1464
|
+
// and are not currently fired for providers without MCP (e.g. Devin).
|
|
1425
1465
|
case "unread_mentions": {
|
|
1426
1466
|
const result = await resolveTemplateAsync("task.trigger.unread_mentions", {
|
|
1427
1467
|
mention_count: trigger.count || "unread",
|
|
@@ -1461,6 +1501,28 @@ async function buildPromptForTrigger(
|
|
|
1461
1501
|
return result.text;
|
|
1462
1502
|
}
|
|
1463
1503
|
|
|
1504
|
+
case "budget_refused": {
|
|
1505
|
+
// DEFENSIVE: refusals are normally handled in the poll loop *before*
|
|
1506
|
+
// reaching buildPromptForTrigger (the loop short-circuits on
|
|
1507
|
+
// `trigger.type === "budget_refused"` to apply back-off + continue).
|
|
1508
|
+
// This branch exists purely to keep the switch exhaustive in TypeScript
|
|
1509
|
+
// and as future-refactor protection. It should never run in tested
|
|
1510
|
+
// paths. Returning the default prompt is the safe no-op behavior.
|
|
1511
|
+
const payload = JSON.stringify({
|
|
1512
|
+
type: trigger.type,
|
|
1513
|
+
cause: trigger.cause,
|
|
1514
|
+
agentSpend: trigger.agentSpend,
|
|
1515
|
+
agentBudget: trigger.agentBudget,
|
|
1516
|
+
globalSpend: trigger.globalSpend,
|
|
1517
|
+
globalBudget: trigger.globalBudget,
|
|
1518
|
+
resetAt: trigger.resetAt,
|
|
1519
|
+
});
|
|
1520
|
+
console.warn(
|
|
1521
|
+
`[runner] buildPromptForTrigger received budget_refused (defensive branch — should be handled in poll loop): ${scrubSecrets(payload)}`,
|
|
1522
|
+
);
|
|
1523
|
+
return defaultPrompt;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1464
1526
|
default:
|
|
1465
1527
|
return defaultPrompt;
|
|
1466
1528
|
}
|
|
@@ -1549,6 +1611,7 @@ async function spawnProviderProcess(
|
|
|
1549
1611
|
taskId?: string;
|
|
1550
1612
|
model?: string;
|
|
1551
1613
|
cwd?: string;
|
|
1614
|
+
vcsRepo?: string;
|
|
1552
1615
|
},
|
|
1553
1616
|
logDir: string,
|
|
1554
1617
|
isYolo: boolean,
|
|
@@ -1590,6 +1653,7 @@ async function spawnProviderProcess(
|
|
|
1590
1653
|
apiUrl: opts.apiUrl,
|
|
1591
1654
|
apiKey: opts.apiKey,
|
|
1592
1655
|
cwd: opts.cwd || process.cwd(),
|
|
1656
|
+
vcsRepo: opts.vcsRepo,
|
|
1593
1657
|
logFile: opts.logFile,
|
|
1594
1658
|
additionalArgs: opts.additionalArgs,
|
|
1595
1659
|
iteration: opts.iteration,
|
|
@@ -1665,7 +1729,6 @@ async function spawnProviderProcess(
|
|
|
1665
1729
|
|
|
1666
1730
|
// Auto-progress throttle: don't update more than once per 3 seconds
|
|
1667
1731
|
let lastProgressTime = 0;
|
|
1668
|
-
const PROGRESS_THROTTLE_MS = 3000;
|
|
1669
1732
|
|
|
1670
1733
|
// Context usage throttle: max 1 snapshot per 30 seconds
|
|
1671
1734
|
let lastContextPostTime = 0;
|
|
@@ -1675,9 +1738,14 @@ async function spawnProviderProcess(
|
|
|
1675
1738
|
switch (event.type) {
|
|
1676
1739
|
case "session_init":
|
|
1677
1740
|
if (realTaskId) {
|
|
1678
|
-
saveProviderSessionId(
|
|
1679
|
-
|
|
1680
|
-
|
|
1741
|
+
saveProviderSessionId(
|
|
1742
|
+
opts.apiUrl,
|
|
1743
|
+
opts.apiKey,
|
|
1744
|
+
realTaskId,
|
|
1745
|
+
event.sessionId,
|
|
1746
|
+
event.provider,
|
|
1747
|
+
event.providerMeta,
|
|
1748
|
+
).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
|
|
1681
1749
|
} else {
|
|
1682
1750
|
// Pool task: save provider session ID on active session so it can be
|
|
1683
1751
|
// propagated to the real task when the agent claims one
|
|
@@ -1835,6 +1903,19 @@ async function spawnProviderProcess(
|
|
|
1835
1903
|
case "raw_stderr":
|
|
1836
1904
|
prettyPrintStderr(event.content, opts.role);
|
|
1837
1905
|
break;
|
|
1906
|
+
|
|
1907
|
+
case "progress": {
|
|
1908
|
+
if (effectiveTaskId && opts.apiUrl) {
|
|
1909
|
+
const now = Date.now();
|
|
1910
|
+
if (now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
|
|
1911
|
+
lastProgressTime = now;
|
|
1912
|
+
updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, event.message).catch(
|
|
1913
|
+
() => {},
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1838
1919
|
}
|
|
1839
1920
|
});
|
|
1840
1921
|
|
|
@@ -1986,13 +2067,26 @@ async function runProviderIteration(
|
|
|
1986
2067
|
|
|
1987
2068
|
const session = await adapter.createSession(config);
|
|
1988
2069
|
|
|
2070
|
+
let lastAiLoopProgressTime = 0;
|
|
1989
2071
|
session.onEvent((event) => {
|
|
1990
2072
|
if (event.type === "raw_log") prettyPrintLine(event.content, opts.role);
|
|
1991
2073
|
if (event.type === "raw_stderr") prettyPrintStderr(event.content, opts.role);
|
|
1992
2074
|
if (event.type === "session_init" && opts.taskId) {
|
|
1993
|
-
saveProviderSessionId(
|
|
1994
|
-
|
|
1995
|
-
|
|
2075
|
+
saveProviderSessionId(
|
|
2076
|
+
opts.apiUrl,
|
|
2077
|
+
opts.apiKey,
|
|
2078
|
+
opts.taskId,
|
|
2079
|
+
event.sessionId,
|
|
2080
|
+
event.provider,
|
|
2081
|
+
event.providerMeta,
|
|
2082
|
+
).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
|
|
2083
|
+
}
|
|
2084
|
+
if (event.type === "progress" && opts.taskId) {
|
|
2085
|
+
const now = Date.now();
|
|
2086
|
+
if (now - lastAiLoopProgressTime >= PROGRESS_THROTTLE_MS) {
|
|
2087
|
+
lastAiLoopProgressTime = now;
|
|
2088
|
+
updateProgressViaAPI(opts.apiUrl, opts.apiKey, opts.taskId, event.message).catch(() => {});
|
|
2089
|
+
}
|
|
1996
2090
|
}
|
|
1997
2091
|
});
|
|
1998
2092
|
|
|
@@ -2074,7 +2168,14 @@ async function checkCompletedProcesses(
|
|
|
2074
2168
|
).catch(() => {});
|
|
2075
2169
|
}
|
|
2076
2170
|
}
|
|
2077
|
-
await ensureTaskFinished(
|
|
2171
|
+
await ensureTaskFinished(
|
|
2172
|
+
apiConfig,
|
|
2173
|
+
role,
|
|
2174
|
+
taskId,
|
|
2175
|
+
result.exitCode,
|
|
2176
|
+
failureReason,
|
|
2177
|
+
result.output,
|
|
2178
|
+
);
|
|
2078
2179
|
|
|
2079
2180
|
ensure({
|
|
2080
2181
|
id: "worker_process_finished",
|
|
@@ -2272,22 +2373,28 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2272
2373
|
let currentTaskSlackContext: BasePromptArgs["slackContext"] | undefined;
|
|
2273
2374
|
|
|
2274
2375
|
// Generate base prompt (identity fields injected after profile fetch below)
|
|
2376
|
+
const { traits } = adapter;
|
|
2275
2377
|
const buildSystemPrompt = async () => {
|
|
2276
2378
|
return getBasePrompt({
|
|
2277
2379
|
role,
|
|
2278
2380
|
agentId,
|
|
2279
2381
|
swarmUrl,
|
|
2280
2382
|
capabilities,
|
|
2383
|
+
traits,
|
|
2281
2384
|
name: agentProfileName,
|
|
2282
2385
|
description: agentDescription,
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2386
|
+
...(traits.hasLocalEnvironment && {
|
|
2387
|
+
soulMd: agentSoulMd,
|
|
2388
|
+
identityMd: agentIdentityMd,
|
|
2389
|
+
toolsMd: agentToolsMd,
|
|
2390
|
+
claudeMd: agentClaudeMd,
|
|
2391
|
+
}),
|
|
2287
2392
|
repoContext: currentRepoContext,
|
|
2288
2393
|
slackContext: currentTaskSlackContext,
|
|
2289
|
-
|
|
2290
|
-
|
|
2394
|
+
...(traits.hasMcp && {
|
|
2395
|
+
skillsSummary: agentSkillsSummary,
|
|
2396
|
+
mcpServersSummary: agentMcpServersSummary,
|
|
2397
|
+
}),
|
|
2291
2398
|
});
|
|
2292
2399
|
};
|
|
2293
2400
|
|
|
@@ -2738,7 +2845,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2738
2845
|
}
|
|
2739
2846
|
|
|
2740
2847
|
// Build prompt with resume context + memory injection
|
|
2741
|
-
let resumePrompt = await buildResumePrompt(task, adapter.formatCommand.bind(adapter)
|
|
2848
|
+
let resumePrompt = await buildResumePrompt(task, adapter.formatCommand.bind(adapter), {
|
|
2849
|
+
hasMcp: adapter.traits.hasMcp,
|
|
2850
|
+
});
|
|
2742
2851
|
|
|
2743
2852
|
// Inject relevant memories for resumed tasks
|
|
2744
2853
|
const resumeMemoryContext = await fetchRelevantMemories(
|
|
@@ -2841,6 +2950,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2841
2950
|
taskId: task.id,
|
|
2842
2951
|
model: (task as { model?: string }).model,
|
|
2843
2952
|
cwd: resumeCwd,
|
|
2953
|
+
vcsRepo: task.vcsRepo,
|
|
2844
2954
|
},
|
|
2845
2955
|
logDir,
|
|
2846
2956
|
isYolo,
|
|
@@ -2887,6 +2997,11 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2887
2997
|
}
|
|
2888
2998
|
}
|
|
2889
2999
|
|
|
3000
|
+
// Phase 4 — exponential back-off state for `budget_refused` triggers.
|
|
3001
|
+
// Resets to 0 on any non-refused outcome. Lives outside the loop so
|
|
3002
|
+
// state persists across iterations.
|
|
3003
|
+
let consecutiveBudgetRefusals = 0;
|
|
3004
|
+
|
|
2890
3005
|
// Track last finished task check for leads (to avoid re-processing)
|
|
2891
3006
|
while (true) {
|
|
2892
3007
|
// Ping server on each iteration to keep status updated
|
|
@@ -2957,6 +3072,36 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2957
3072
|
});
|
|
2958
3073
|
|
|
2959
3074
|
if (trigger) {
|
|
3075
|
+
// Phase 4 — server refused to admit a claim because the agent or
|
|
3076
|
+
// global budget is exhausted. Log a structured payload (scrubbed
|
|
3077
|
+
// at egress per project convention) and back off exponentially.
|
|
3078
|
+
// We deliberately `continue` BEFORE the empty-poll counter logic
|
|
3079
|
+
// below — refusals are not empty polls.
|
|
3080
|
+
if (trigger.type === "budget_refused") {
|
|
3081
|
+
consecutiveBudgetRefusals++;
|
|
3082
|
+
const backoffMs = computeBudgetBackoffMs(consecutiveBudgetRefusals, PollIntervalMs);
|
|
3083
|
+
const refusalPayload = JSON.stringify({
|
|
3084
|
+
event: "budget_refused",
|
|
3085
|
+
cause: trigger.cause,
|
|
3086
|
+
agentSpend: trigger.agentSpend,
|
|
3087
|
+
agentBudget: trigger.agentBudget,
|
|
3088
|
+
globalSpend: trigger.globalSpend,
|
|
3089
|
+
globalBudget: trigger.globalBudget,
|
|
3090
|
+
resetAt: trigger.resetAt,
|
|
3091
|
+
consecutiveRefusals: consecutiveBudgetRefusals,
|
|
3092
|
+
backoffMs,
|
|
3093
|
+
});
|
|
3094
|
+
console.log(
|
|
3095
|
+
`[${role}] budget_refused — backing off ${backoffMs}ms: ${scrubSecrets(refusalPayload)}`,
|
|
3096
|
+
);
|
|
3097
|
+
await Bun.sleep(backoffMs);
|
|
3098
|
+
continue;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// Any other non-null trigger means we're being admitted normally —
|
|
3102
|
+
// reset the back-off so the next refusal starts at base interval.
|
|
3103
|
+
consecutiveBudgetRefusals = 0;
|
|
3104
|
+
|
|
2960
3105
|
console.log(`[${role}] Trigger received: ${trigger.type}`);
|
|
2961
3106
|
|
|
2962
3107
|
if (
|
|
@@ -2985,6 +3130,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2985
3130
|
trigger,
|
|
2986
3131
|
prompt,
|
|
2987
3132
|
adapter.formatCommand.bind(adapter),
|
|
3133
|
+
{ hasMcp: adapter.traits.hasMcp },
|
|
2988
3134
|
);
|
|
2989
3135
|
|
|
2990
3136
|
// Enrich prompt with relevant memories from past sessions
|
|
@@ -3147,6 +3293,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3147
3293
|
taskId: trigger.taskId,
|
|
3148
3294
|
model: taskModel,
|
|
3149
3295
|
cwd: effectiveCwd,
|
|
3296
|
+
vcsRepo: taskVcsRepo,
|
|
3150
3297
|
},
|
|
3151
3298
|
logDir,
|
|
3152
3299
|
isYolo,
|
|
@@ -111,14 +111,16 @@ Task: "{{task_description}}"
|
|
|
111
111
|
Previous Progress:
|
|
112
112
|
{{progress}}
|
|
113
113
|
|
|
114
|
-
Continue from where you left off. Review the progress above and complete the remaining work.
|
|
115
|
-
|
|
116
|
-
When done, use \`store-progress\` with status: "completed" and include your output.`,
|
|
114
|
+
Continue from where you left off. Review the progress above and complete the remaining work.{{completion_instructions}}`,
|
|
117
115
|
variables: [
|
|
118
116
|
{ name: "work_on_task_cmd", description: "Formatted /work-on-task command" },
|
|
119
117
|
{ name: "task_id", description: "Task ID" },
|
|
120
118
|
{ name: "task_description", description: "Original task description" },
|
|
121
119
|
{ name: "progress", description: "Previous progress text" },
|
|
120
|
+
{
|
|
121
|
+
name: "completion_instructions",
|
|
122
|
+
description: "Completion instructions (empty for providers without MCP)",
|
|
123
|
+
},
|
|
122
124
|
],
|
|
123
125
|
category: "task_lifecycle",
|
|
124
126
|
});
|
|
@@ -132,13 +134,15 @@ registerTemplate({
|
|
|
132
134
|
|
|
133
135
|
Task: "{{task_description}}"
|
|
134
136
|
|
|
135
|
-
No progress was saved before the interruption. Start the task fresh but be aware files may have been partially modified.
|
|
136
|
-
|
|
137
|
-
When done, use \`store-progress\` with status: "completed" and include your output.`,
|
|
137
|
+
No progress was saved before the interruption. Start the task fresh but be aware files may have been partially modified.{{completion_instructions}}`,
|
|
138
138
|
variables: [
|
|
139
139
|
{ name: "work_on_task_cmd", description: "Formatted /work-on-task command" },
|
|
140
140
|
{ name: "task_id", description: "Task ID" },
|
|
141
141
|
{ name: "task_description", description: "Original task description" },
|
|
142
|
+
{
|
|
143
|
+
name: "completion_instructions",
|
|
144
|
+
description: "Completion instructions (empty for providers without MCP)",
|
|
145
|
+
},
|
|
142
146
|
],
|
|
143
147
|
category: "task_lifecycle",
|
|
144
148
|
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Phase 6: REST CRUD for daily USD budgets per (scope, scopeId).
|
|
2
|
+
//
|
|
3
|
+
// Auth defaults to apiKey via the `route()` factory (existing convention).
|
|
4
|
+
// Every PUT and DELETE writes a row to `agent_log` with eventType
|
|
5
|
+
// `budget.upserted` / `budget.deleted` so compliance reviewers can audit
|
|
6
|
+
// "who set what budget when". The raw API key is NEVER logged — we record a
|
|
7
|
+
// short SHA-256 fingerprint instead, scrubbed via `scrubSecrets` for safety.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import {
|
|
13
|
+
createLogEntry,
|
|
14
|
+
deleteBudget,
|
|
15
|
+
getBudget,
|
|
16
|
+
getBudgets,
|
|
17
|
+
getRecentBudgetRefusalNotifications,
|
|
18
|
+
upsertBudget,
|
|
19
|
+
} from "../be/db";
|
|
20
|
+
import { BudgetRefusalNotificationSchema, BudgetSchema, BudgetScopeSchema } from "../types";
|
|
21
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
22
|
+
import { route } from "./route-def";
|
|
23
|
+
import { json, jsonError } from "./utils";
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Short SHA-256 fingerprint of the bearer token for audit-log purposes. Never
|
|
29
|
+
* logs the raw key — only the first 8 hex chars of the digest. Defense in
|
|
30
|
+
* depth: also runs the result through `scrubSecrets` so any future change
|
|
31
|
+
* that accidentally puts the raw key here cannot leak it through logs.
|
|
32
|
+
*/
|
|
33
|
+
function apiKeyFingerprint(req: IncomingMessage): string {
|
|
34
|
+
const authHeader = req.headers.authorization;
|
|
35
|
+
const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
36
|
+
if (!providedKey) return "";
|
|
37
|
+
const digest = createHash("sha256").update(providedKey).digest("hex").slice(0, 8);
|
|
38
|
+
return scrubSecrets(digest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const ScopeIdSchema = z
|
|
44
|
+
.string()
|
|
45
|
+
.max(255)
|
|
46
|
+
.describe("Scope identifier — empty string for global, agent UUID otherwise");
|
|
47
|
+
|
|
48
|
+
const listBudgets = route({
|
|
49
|
+
method: "get",
|
|
50
|
+
path: "/api/budgets",
|
|
51
|
+
pattern: ["api", "budgets"],
|
|
52
|
+
summary: "List all configured budget rows",
|
|
53
|
+
tags: ["Budgets"],
|
|
54
|
+
responses: {
|
|
55
|
+
200: { description: "Budget list", schema: z.object({ budgets: z.array(BudgetSchema) }) },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const listBudgetRefusals = route({
|
|
60
|
+
method: "get",
|
|
61
|
+
path: "/api/budgets/refusals",
|
|
62
|
+
pattern: ["api", "budgets", "refusals"],
|
|
63
|
+
summary: "List recent budget refusal notifications",
|
|
64
|
+
tags: ["Budgets"],
|
|
65
|
+
query: z.object({
|
|
66
|
+
limit: z.coerce.number().int().positive().max(500).optional(),
|
|
67
|
+
}),
|
|
68
|
+
responses: {
|
|
69
|
+
200: {
|
|
70
|
+
description: "Recent budget refusals (newest first)",
|
|
71
|
+
schema: z.object({ refusals: z.array(BudgetRefusalNotificationSchema) }),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const getBudgetByScope = route({
|
|
77
|
+
method: "get",
|
|
78
|
+
path: "/api/budgets/{scope}/{scopeId}",
|
|
79
|
+
pattern: ["api", "budgets", null, null],
|
|
80
|
+
summary: "Get a single budget row",
|
|
81
|
+
tags: ["Budgets"],
|
|
82
|
+
params: z.object({ scope: BudgetScopeSchema, scopeId: ScopeIdSchema }),
|
|
83
|
+
responses: {
|
|
84
|
+
200: { description: "Budget row", schema: BudgetSchema },
|
|
85
|
+
404: { description: "Budget not configured" },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const upsertBudgetRoute = route({
|
|
90
|
+
method: "put",
|
|
91
|
+
path: "/api/budgets/{scope}/{scopeId}",
|
|
92
|
+
pattern: ["api", "budgets", null, null],
|
|
93
|
+
summary: "Create or update a budget row",
|
|
94
|
+
tags: ["Budgets"],
|
|
95
|
+
params: z.object({ scope: BudgetScopeSchema, scopeId: ScopeIdSchema }),
|
|
96
|
+
body: z.object({
|
|
97
|
+
dailyBudgetUsd: z.number().nonnegative(),
|
|
98
|
+
}),
|
|
99
|
+
responses: {
|
|
100
|
+
200: { description: "Budget upserted", schema: BudgetSchema },
|
|
101
|
+
400: { description: "Validation error" },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const deleteBudgetRoute = route({
|
|
106
|
+
method: "delete",
|
|
107
|
+
path: "/api/budgets/{scope}/{scopeId}",
|
|
108
|
+
pattern: ["api", "budgets", null, null],
|
|
109
|
+
summary: "Delete a budget row",
|
|
110
|
+
tags: ["Budgets"],
|
|
111
|
+
params: z.object({ scope: BudgetScopeSchema, scopeId: ScopeIdSchema }),
|
|
112
|
+
responses: {
|
|
113
|
+
204: { description: "Budget deleted" },
|
|
114
|
+
404: { description: "Budget not configured" },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export async function handleBudgets(
|
|
121
|
+
req: IncomingMessage,
|
|
122
|
+
res: ServerResponse,
|
|
123
|
+
pathSegments: string[],
|
|
124
|
+
queryParams: URLSearchParams,
|
|
125
|
+
_myAgentId: string | undefined,
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
// GET /api/budgets — list
|
|
128
|
+
if (listBudgets.match(req.method, pathSegments)) {
|
|
129
|
+
const parsed = await listBudgets.parse(req, res, pathSegments, queryParams);
|
|
130
|
+
if (!parsed) return true;
|
|
131
|
+
json(res, { budgets: getBudgets() });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// GET /api/budgets/refusals — must come BEFORE the {scope}/{scopeId} routes
|
|
136
|
+
// since those use a 4-segment pattern; this is 3 segments so they are
|
|
137
|
+
// disjoint, but conceptually the literal must win over the wildcards.
|
|
138
|
+
if (listBudgetRefusals.match(req.method, pathSegments)) {
|
|
139
|
+
const parsed = await listBudgetRefusals.parse(req, res, pathSegments, queryParams);
|
|
140
|
+
if (!parsed) return true;
|
|
141
|
+
const limit = parsed.query.limit ?? 50;
|
|
142
|
+
json(res, { refusals: getRecentBudgetRefusalNotifications(limit) });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The single-row routes share the pattern `["api", "budgets", :scope, :scopeId]`.
|
|
147
|
+
// URL-encoded empty `scopeId` ('') is used for the global scope; the
|
|
148
|
+
// route-def `pattern` already requires a non-empty segment, so callers
|
|
149
|
+
// targeting global must pass `'-'` or any non-empty placeholder. To support
|
|
150
|
+
// the spec's "scopeId='' for global" we accept the literal `_global` and
|
|
151
|
+
// map it back here.
|
|
152
|
+
// Note: HTTP path segments cannot be empty strings (filter(Boolean) drops
|
|
153
|
+
// them), so we use `_global` as the wire-format placeholder.
|
|
154
|
+
|
|
155
|
+
if (getBudgetByScope.match(req.method, pathSegments)) {
|
|
156
|
+
const parsed = await getBudgetByScope.parse(req, res, pathSegments, queryParams);
|
|
157
|
+
if (!parsed) return true;
|
|
158
|
+
const scopeId = parsed.params.scopeId === "_global" ? "" : parsed.params.scopeId;
|
|
159
|
+
const row = getBudget(parsed.params.scope, scopeId);
|
|
160
|
+
if (!row) {
|
|
161
|
+
jsonError(res, "Budget not configured", 404);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
json(res, row);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (upsertBudgetRoute.match(req.method, pathSegments)) {
|
|
169
|
+
const parsed = await upsertBudgetRoute.parse(req, res, pathSegments, queryParams);
|
|
170
|
+
if (!parsed) return true;
|
|
171
|
+
const scopeId = parsed.params.scopeId === "_global" ? "" : parsed.params.scopeId;
|
|
172
|
+
|
|
173
|
+
const before = getBudget(parsed.params.scope, scopeId);
|
|
174
|
+
const updated = upsertBudget(parsed.params.scope, scopeId, parsed.body.dailyBudgetUsd);
|
|
175
|
+
|
|
176
|
+
createLogEntry({
|
|
177
|
+
eventType: "budget.upserted",
|
|
178
|
+
metadata: {
|
|
179
|
+
scope: parsed.params.scope,
|
|
180
|
+
scopeId,
|
|
181
|
+
before: before ? { dailyBudgetUsd: before.dailyBudgetUsd } : null,
|
|
182
|
+
after: { dailyBudgetUsd: updated.dailyBudgetUsd },
|
|
183
|
+
apiKeyFingerprint: apiKeyFingerprint(req),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
json(res, updated);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (deleteBudgetRoute.match(req.method, pathSegments)) {
|
|
192
|
+
const parsed = await deleteBudgetRoute.parse(req, res, pathSegments, queryParams);
|
|
193
|
+
if (!parsed) return true;
|
|
194
|
+
const scopeId = parsed.params.scopeId === "_global" ? "" : parsed.params.scopeId;
|
|
195
|
+
|
|
196
|
+
const before = getBudget(parsed.params.scope, scopeId);
|
|
197
|
+
const deleted = deleteBudget(parsed.params.scope, scopeId);
|
|
198
|
+
if (!deleted) {
|
|
199
|
+
jsonError(res, "Budget not configured", 404);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
createLogEntry({
|
|
204
|
+
eventType: "budget.deleted",
|
|
205
|
+
metadata: {
|
|
206
|
+
scope: parsed.params.scope,
|
|
207
|
+
scopeId,
|
|
208
|
+
before: before ? { dailyBudgetUsd: before.dailyBudgetUsd } : null,
|
|
209
|
+
apiKeyFingerprint: apiKeyFingerprint(req),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
res.writeHead(204);
|
|
214
|
+
res.end();
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return false;
|
|
219
|
+
}
|