@desplega.ai/agent-swarm 1.71.2 → 1.72.1

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 (64) 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 +338 -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
  63. package/src/workflows/executors/raw-llm.ts +1 -1
  64. package/src/workflows/executors/validate.ts +1 -1
@@ -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({ claudeSessionId }),
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 (taskObj?.outputSchema && typeof taskObj.outputSchema === "object") {
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(opts.apiUrl, opts.apiKey, realTaskId, event.sessionId).catch(
1679
- (err) => console.warn(`[runner] Failed to save session ID: ${err}`),
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(opts.apiUrl, opts.apiKey, opts.taskId, event.sessionId).catch((err) =>
1994
- console.warn(`[runner] Failed to save session ID: ${err}`),
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(apiConfig, role, taskId, result.exitCode, failureReason);
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
- soulMd: agentSoulMd,
2284
- identityMd: agentIdentityMd,
2285
- toolsMd: agentToolsMd,
2286
- claudeMd: agentClaudeMd,
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
- skillsSummary: agentSkillsSummary,
2290
- mcpServersSummary: agentMcpServersSummary,
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
+ }