@desplega.ai/agent-swarm 1.88.0 → 1.90.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 +7 -0
- package/openapi.json +41 -1
- package/package.json +3 -2
- package/plugin/skills/composio/SKILL.md +173 -0
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/be/db.ts +353 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3413 -1423
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +80 -12
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +6 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
|
@@ -11,6 +11,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import artifactsConfig from "../../../templates/skills/artifacts/config.json" with { type: "text" };
|
|
13
13
|
import artifactsContent from "../../../templates/skills/artifacts/content.md" with { type: "text" };
|
|
14
|
+
import attioInteractionConfig from "../../../templates/skills/attio-interaction/config.json" with {
|
|
15
|
+
type: "text",
|
|
16
|
+
};
|
|
17
|
+
import attioInteractionContent from "../../../templates/skills/attio-interaction/content.md" with {
|
|
18
|
+
type: "text",
|
|
19
|
+
};
|
|
14
20
|
import kvStorageConfig from "../../../templates/skills/kv-storage/config.json" with {
|
|
15
21
|
type: "text",
|
|
16
22
|
};
|
|
@@ -61,6 +67,7 @@ export type SeedSkill = {
|
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
const BUILT_IN_SKILL_SOURCES = [
|
|
70
|
+
{ config: attioInteractionConfig, body: attioInteractionContent },
|
|
64
71
|
{ config: artifactsConfig, body: artifactsContent },
|
|
65
72
|
{ config: kvStorageConfig, body: kvStorageContent },
|
|
66
73
|
{ config: pagesConfig, body: pagesContent },
|
package/src/cli.tsx
CHANGED
|
@@ -255,6 +255,19 @@ const COMMAND_HELP: Record<
|
|
|
255
255
|
options: " -h, --help Show this help",
|
|
256
256
|
examples: [` ${binName} artifact serve`, ` ${binName} artifact help`].join("\n"),
|
|
257
257
|
},
|
|
258
|
+
x: {
|
|
259
|
+
usage: `${binName} x <target> [args]`,
|
|
260
|
+
description:
|
|
261
|
+
"Execute external command routes. Prototype target: composio routes HTTP requests to the Composio REST API using COMPOSIO_API_KEY.",
|
|
262
|
+
options: [
|
|
263
|
+
" composio <method> <path> Route to the Composio REST API",
|
|
264
|
+
" -h, --help Show this help",
|
|
265
|
+
].join("\n"),
|
|
266
|
+
examples: [
|
|
267
|
+
` ${binName} x composio GET /tools`,
|
|
268
|
+
` ${binName} x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`,
|
|
269
|
+
].join("\n"),
|
|
270
|
+
},
|
|
258
271
|
scripts: {
|
|
259
272
|
usage: `${binName} scripts reembed`,
|
|
260
273
|
description: "Maintenance commands for reusable swarm scripts.",
|
|
@@ -369,6 +382,7 @@ function printHelp(command?: string) {
|
|
|
369
382
|
["claude", "Run Claude CLI"],
|
|
370
383
|
["hook", "Handle Claude Code hook events (stdin)"],
|
|
371
384
|
["artifact", "Manage agent artifacts"],
|
|
385
|
+
["x", "Execute external command routes"],
|
|
372
386
|
["scripts", "Reusable scripts maintenance"],
|
|
373
387
|
["docs", "Open documentation (--open to launch in browser)"],
|
|
374
388
|
["codex-login", "Authenticate Codex via ChatGPT OAuth"],
|
|
@@ -612,6 +626,10 @@ if (args.showHelp || args.command === "help" || args.command === undefined) {
|
|
|
612
626
|
port: args.port,
|
|
613
627
|
key: args.key,
|
|
614
628
|
});
|
|
629
|
+
} else if (args.command === "x") {
|
|
630
|
+
const xArgs = process.argv.slice(process.argv.indexOf("x") + 1);
|
|
631
|
+
const { runXCommand } = await import("./commands/x");
|
|
632
|
+
await runXCommand(xArgs);
|
|
615
633
|
} else if (args.command === "scripts") {
|
|
616
634
|
const scriptsArgs = process.argv.slice(process.argv.indexOf("scripts") + 1);
|
|
617
635
|
if (args.showHelp || scriptsArgs[0] !== "reembed") {
|
package/src/commands/runner.ts
CHANGED
|
@@ -126,14 +126,88 @@ async function readClaudeMd(clonePath: string, role: string): Promise<string | n
|
|
|
126
126
|
return null;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export type SwarmAutostash = { ref: string; message: string };
|
|
130
|
+
|
|
131
|
+
async function listSwarmAutostashes(clonePath: string, role: string): Promise<SwarmAutostash[]> {
|
|
132
|
+
try {
|
|
133
|
+
const result = await Bun.$`cd ${clonePath} && git stash list --format=%gd%x09%s`.quiet();
|
|
134
|
+
return result
|
|
135
|
+
.text()
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((line) => line.trim())
|
|
138
|
+
.filter((line) => line.includes("swarm-autostash"))
|
|
139
|
+
.flatMap((line) => {
|
|
140
|
+
const [ref, ...messageParts] = line.split("\t");
|
|
141
|
+
if (!ref) return [];
|
|
142
|
+
return [{ ref, message: messageParts.join("\t") || line }];
|
|
143
|
+
});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[${role}] Could not inspect git stashes for ${clonePath}: ${scrubSecrets((err as Error).message)}`,
|
|
147
|
+
);
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function refreshExistingRepoForTask(
|
|
153
|
+
repoConfig: { name: string; clonePath: string; defaultBranch: string },
|
|
154
|
+
role: string,
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const { name, clonePath, defaultBranch } = repoConfig;
|
|
157
|
+
const statusResult = await Bun.$`cd ${clonePath} && git status --porcelain`.quiet();
|
|
158
|
+
const statusOutput = statusResult.text().trim();
|
|
159
|
+
let stashMessage: string | null = null;
|
|
160
|
+
|
|
161
|
+
if (statusOutput !== "") {
|
|
162
|
+
stashMessage = `swarm-autostash ${defaultBranch} ${new Date().toISOString()}`;
|
|
163
|
+
try {
|
|
164
|
+
console.log(`[${role}] Auto-stashing pending work in ${name}: ${stashMessage}`);
|
|
165
|
+
await Bun.$`cd ${clonePath} && git stash push --include-untracked -m ${stashMessage}`.quiet();
|
|
166
|
+
console.log(`[${role}] Auto-stashed pending work in ${name}`);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const errorMsg = scrubSecrets((err as Error).message);
|
|
169
|
+
console.warn(`[${role}] Could not auto-stash ${name}, skipping pull: ${errorMsg}`);
|
|
170
|
+
return `The repo "${name}" at ${clonePath} has uncommitted changes, but auto-stash failed: ${errorMsg}. A git pull was skipped to avoid losing work.`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
console.log(`[${role}] Refreshing ${name} from origin/${defaultBranch}...`);
|
|
176
|
+
const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
|
|
177
|
+
const remoteRef = `refs/remotes/origin/${defaultBranch}`;
|
|
178
|
+
await Bun.$`cd ${clonePath} && git fetch origin ${fetchSpec}`.quiet();
|
|
179
|
+
await Bun.$`cd ${clonePath} && git merge --no-edit --no-stat ${remoteRef}`.quiet();
|
|
180
|
+
console.log(`[${role}] Refreshed ${name}`);
|
|
181
|
+
return null;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const errorMsg = scrubSecrets((err as Error).message);
|
|
184
|
+
console.warn(`[${role}] Could not refresh ${name}: ${errorMsg}`);
|
|
185
|
+
try {
|
|
186
|
+
await Bun.$`cd ${clonePath} && git merge --abort`.quiet();
|
|
187
|
+
} catch {
|
|
188
|
+
// No merge in progress, or abort failed. The original refresh warning is
|
|
189
|
+
// the actionable signal; repo setup remains best-effort.
|
|
190
|
+
}
|
|
191
|
+
const stashNote = stashMessage
|
|
192
|
+
? ` Pending work was preserved in git stash "${stashMessage}".`
|
|
193
|
+
: "";
|
|
194
|
+
return `The repo "${name}" at ${clonePath} could not be refreshed from origin/${defaultBranch}: ${errorMsg}.${stashNote}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
129
198
|
/**
|
|
130
199
|
* Ensure a repo is cloned and up-to-date for a task.
|
|
131
200
|
* Returns { clonePath, claudeMd, warning }.
|
|
132
201
|
*/
|
|
133
|
-
async function ensureRepoForTask(
|
|
202
|
+
export async function ensureRepoForTask(
|
|
134
203
|
repoConfig: { url: string; name: string; clonePath: string; defaultBranch: string },
|
|
135
204
|
role: string,
|
|
136
|
-
): Promise<{
|
|
205
|
+
): Promise<{
|
|
206
|
+
clonePath: string;
|
|
207
|
+
claudeMd: string | null;
|
|
208
|
+
warning: string | null;
|
|
209
|
+
autoStashes: SwarmAutostash[];
|
|
210
|
+
}> {
|
|
137
211
|
const { url, name, clonePath, defaultBranch } = repoConfig;
|
|
138
212
|
|
|
139
213
|
try {
|
|
@@ -158,28 +232,20 @@ async function ensureRepoForTask(
|
|
|
158
232
|
console.log(`[${role}] Cloned ${name}`);
|
|
159
233
|
} else {
|
|
160
234
|
console.log(`[${role}] Repo ${name} already cloned at ${clonePath}`);
|
|
161
|
-
|
|
162
|
-
const statusOutput = statusResult.text().trim();
|
|
163
|
-
|
|
164
|
-
if (statusOutput === "") {
|
|
165
|
-
console.log(`[${role}] Pulling ${name} (${defaultBranch})...`);
|
|
166
|
-
await Bun.$`cd ${clonePath} && git pull origin ${defaultBranch} --ff-only`.quiet();
|
|
167
|
-
console.log(`[${role}] Pulled ${name}`);
|
|
168
|
-
} else {
|
|
169
|
-
console.warn(`[${role}] Repo ${name} has uncommitted changes, skipping pull`);
|
|
170
|
-
warning = `The repo "${name}" at ${clonePath} has uncommitted changes. A git pull was skipped to avoid losing work. You may need to commit or stash changes before pulling updates.`;
|
|
171
|
-
}
|
|
235
|
+
warning = await refreshExistingRepoForTask({ name, clonePath, defaultBranch }, role);
|
|
172
236
|
}
|
|
173
237
|
|
|
174
238
|
const claudeMd = await readClaudeMd(clonePath, role);
|
|
175
|
-
|
|
239
|
+
const autoStashes = await listSwarmAutostashes(clonePath, role);
|
|
240
|
+
return { clonePath, claudeMd, warning, autoStashes };
|
|
176
241
|
} catch (err) {
|
|
177
|
-
const errorMsg = (err as Error).message;
|
|
242
|
+
const errorMsg = scrubSecrets((err as Error).message);
|
|
178
243
|
console.warn(`[${role}] Error setting up repo ${name}: ${errorMsg}`);
|
|
179
244
|
const warning = `Failed to clone/setup repo "${name}" at ${clonePath}: ${errorMsg}. The repo may not be available. You may need to clone it manually.`;
|
|
180
245
|
// Only return clonePath if the directory actually exists (clone may have failed)
|
|
181
246
|
const cloneExists = existsSync(clonePath);
|
|
182
|
-
|
|
247
|
+
const autoStashes = cloneExists ? await listSwarmAutostashes(clonePath, role) : [];
|
|
248
|
+
return { clonePath: cloneExists ? clonePath : "", claudeMd: null, warning, autoStashes };
|
|
183
249
|
}
|
|
184
250
|
}
|
|
185
251
|
|
|
@@ -1692,6 +1758,31 @@ async function cleanupActiveSessions(config: ApiConfig): Promise<void> {
|
|
|
1692
1758
|
}
|
|
1693
1759
|
}
|
|
1694
1760
|
|
|
1761
|
+
/** Reset orphaned in-progress tasks for this agent back to pending dispatch. */
|
|
1762
|
+
async function recoverOrphanedInProgressTasks(config: ApiConfig): Promise<number> {
|
|
1763
|
+
const headers: Record<string, string> = {
|
|
1764
|
+
"Content-Type": "application/json",
|
|
1765
|
+
"X-Agent-ID": config.agentId,
|
|
1766
|
+
};
|
|
1767
|
+
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
|
1768
|
+
try {
|
|
1769
|
+
const response = await fetch(`${config.apiUrl}/api/active-sessions/recover-orphaned-tasks`, {
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
headers,
|
|
1772
|
+
body: JSON.stringify({ agentId: config.agentId, minAgeSeconds: 60 }),
|
|
1773
|
+
});
|
|
1774
|
+
if (!response.ok) {
|
|
1775
|
+
console.warn(`[runner] Failed to recover orphaned tasks: ${response.status}`);
|
|
1776
|
+
return 0;
|
|
1777
|
+
}
|
|
1778
|
+
const data = (await response.json()) as { recovered?: number };
|
|
1779
|
+
return data.recovered ?? 0;
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
console.warn(`[runner] Error recovering orphaned tasks: ${error}`);
|
|
1782
|
+
return 0;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1695
1786
|
/** Trigger a heartbeat sweep via the API (lead startup self-check) */
|
|
1696
1787
|
async function triggerHeartbeatSweep(config: ApiConfig): Promise<boolean> {
|
|
1697
1788
|
try {
|
|
@@ -1743,7 +1834,7 @@ interface Trigger {
|
|
|
1743
1834
|
text?: string;
|
|
1744
1835
|
}>;
|
|
1745
1836
|
cursorUpdates?: Array<{ channelId: string; ts: string }>; // Deferred cursor commits for channel_activity
|
|
1746
|
-
requestedBy?: { name: string; email?: string };
|
|
1837
|
+
requestedBy?: { name: string; email?: string; role?: string; notes?: string };
|
|
1747
1838
|
// Phase 4 — budget_refused fields. The server emits this envelope from
|
|
1748
1839
|
// /api/poll and MCP task-action accept when an admission gate refuses to
|
|
1749
1840
|
// let the agent claim a task. Worker reads cause + reset/spend/budget for
|
|
@@ -1766,6 +1857,26 @@ interface PollOptions {
|
|
|
1766
1857
|
since?: string; // Optional: for filtering finished tasks
|
|
1767
1858
|
}
|
|
1768
1859
|
|
|
1860
|
+
type RequesterProfile = NonNullable<Trigger["requestedBy"]>;
|
|
1861
|
+
|
|
1862
|
+
export async function buildRequesterProfilePrompt(
|
|
1863
|
+
requestedBy: RequesterProfile | undefined,
|
|
1864
|
+
): Promise<string> {
|
|
1865
|
+
if (!requestedBy?.role && !requestedBy?.notes) return "";
|
|
1866
|
+
|
|
1867
|
+
const notes = requestedBy.notes?.trim();
|
|
1868
|
+
const notesSection = notes
|
|
1869
|
+
? `\nTheir stated notes for how you should respond and act:\n${notes}`
|
|
1870
|
+
: "";
|
|
1871
|
+
const result = await resolveTemplateAsync("task.requester.profile", {
|
|
1872
|
+
requester_name: requestedBy.name,
|
|
1873
|
+
requester_role_suffix: requestedBy.role ? ` (${requestedBy.role})` : "",
|
|
1874
|
+
requester_notes_section: notesSection,
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
return result.skipped ? "" : result.text.trim();
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1769
1880
|
/** Register agent via HTTP API */
|
|
1770
1881
|
async function registerAgent(opts: {
|
|
1771
1882
|
apiUrl: string;
|
|
@@ -3608,6 +3719,12 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3608
3719
|
// Clean up any stale active sessions from previous runs (crash recovery)
|
|
3609
3720
|
await cleanupActiveSessions(apiConfig);
|
|
3610
3721
|
console.log(`[${role}] Cleaned up stale active sessions`);
|
|
3722
|
+
const startupRecoveredOrphans = await recoverOrphanedInProgressTasks(apiConfig);
|
|
3723
|
+
if (startupRecoveredOrphans > 0) {
|
|
3724
|
+
console.log(
|
|
3725
|
+
`[${role}] Recovered ${startupRecoveredOrphans} orphaned in-progress task(s) to pending`,
|
|
3726
|
+
);
|
|
3727
|
+
}
|
|
3611
3728
|
|
|
3612
3729
|
// Fetch full agent profile to get soul/identity content
|
|
3613
3730
|
try {
|
|
@@ -4084,7 +4201,10 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
4084
4201
|
// state persists across iterations.
|
|
4085
4202
|
let consecutiveBudgetRefusals = 0;
|
|
4086
4203
|
|
|
4087
|
-
//
|
|
4204
|
+
// Throttle orphan recovery so it runs periodically while the worker is idle or under capacity.
|
|
4205
|
+
let lastOrphanRecoveryAt = 0;
|
|
4206
|
+
const ORPHAN_RECOVERY_INTERVAL_MS = 60_000;
|
|
4207
|
+
|
|
4088
4208
|
while (true) {
|
|
4089
4209
|
// Ping server on each iteration to keep status updated
|
|
4090
4210
|
await pingServer(apiConfig, role);
|
|
@@ -4180,6 +4300,16 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
4180
4300
|
|
|
4181
4301
|
// Only poll if we have capacity
|
|
4182
4302
|
if (state.activeTasks.size < state.maxConcurrent) {
|
|
4303
|
+
if (Date.now() - lastOrphanRecoveryAt > ORPHAN_RECOVERY_INTERVAL_MS) {
|
|
4304
|
+
lastOrphanRecoveryAt = Date.now();
|
|
4305
|
+
const recoveredOrphans = await recoverOrphanedInProgressTasks(apiConfig);
|
|
4306
|
+
if (recoveredOrphans > 0) {
|
|
4307
|
+
console.log(
|
|
4308
|
+
`[${role}] Recovered ${recoveredOrphans} orphaned in-progress task(s) to pending`,
|
|
4309
|
+
);
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4183
4313
|
console.log(
|
|
4184
4314
|
`[${role}] Polling for triggers (${state.activeTasks.size}/${state.maxConcurrent} active)...`,
|
|
4185
4315
|
);
|
|
@@ -4419,10 +4549,11 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
4419
4549
|
|
|
4420
4550
|
// Rebuild system prompt with per-task repo context
|
|
4421
4551
|
const taskBasePrompt = await buildSystemPrompt();
|
|
4422
|
-
const
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4552
|
+
const requesterProfilePrompt = await buildRequesterProfilePrompt(trigger.requestedBy);
|
|
4553
|
+
const taskPromptParts = [taskBasePrompt, requesterProfilePrompt, additionalSystemPrompt]
|
|
4554
|
+
.filter((part): part is string => Boolean(part))
|
|
4555
|
+
.join("\n\n");
|
|
4556
|
+
const taskSystemPrompt = taskPromptParts + cwdWarning;
|
|
4426
4557
|
|
|
4427
4558
|
iteration++;
|
|
4428
4559
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_COMPOSIO_BASE_URL,
|
|
4
|
+
executeComposioRequest,
|
|
5
|
+
formatComposioResultForCli,
|
|
6
|
+
parseComposioArgs,
|
|
7
|
+
} from "../x/composio";
|
|
8
|
+
|
|
9
|
+
interface XCommandDeps {
|
|
10
|
+
env?: Record<string, string | undefined>;
|
|
11
|
+
error?: (message: string) => void;
|
|
12
|
+
exit?: (code: number) => void;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
log?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { parseComposioArgs } from "../x/composio";
|
|
18
|
+
|
|
19
|
+
export async function runXCommand(argv: string[], deps: XCommandDeps = {}): Promise<void> {
|
|
20
|
+
const [target, ...rest] = argv;
|
|
21
|
+
const log = deps.log ?? console.log;
|
|
22
|
+
const error = deps.error ?? console.error;
|
|
23
|
+
const exit = deps.exit ?? process.exit;
|
|
24
|
+
|
|
25
|
+
if (!target || target === "help" || target === "-h" || target === "--help") {
|
|
26
|
+
printXHelp(log);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
switch (target) {
|
|
31
|
+
case "composio":
|
|
32
|
+
await runComposioCommand(rest, deps);
|
|
33
|
+
return;
|
|
34
|
+
default:
|
|
35
|
+
error(`Unknown x target: ${target}`);
|
|
36
|
+
printXHelp(error);
|
|
37
|
+
exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runComposioCommand(argv: string[], deps: XCommandDeps = {}): Promise<void> {
|
|
42
|
+
const log = deps.log ?? console.log;
|
|
43
|
+
const error = deps.error ?? console.error;
|
|
44
|
+
const exit = deps.exit ?? process.exit;
|
|
45
|
+
const env = deps.env ?? process.env;
|
|
46
|
+
|
|
47
|
+
if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
|
|
48
|
+
printComposioHelp(log);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed: ReturnType<typeof parseComposioArgs>;
|
|
53
|
+
try {
|
|
54
|
+
parsed = parseComposioArgs(argv, env);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
error(`composio: ${scrubSecrets(errorMessage(err))}`);
|
|
57
|
+
printComposioHelp(error);
|
|
58
|
+
exit(1);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = await executeComposioRequest(parsed, { env, fetch: deps.fetch });
|
|
63
|
+
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
if (result.status > 0) error(`composio: HTTP ${result.status} ${result.statusText}`.trim());
|
|
66
|
+
else error(`composio: ${result.error ?? result.statusText}`);
|
|
67
|
+
if (result.formattedBody) error(result.formattedBody);
|
|
68
|
+
exit(1);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log(formatComposioResultForCli(result));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errorMessage(err: unknown): string {
|
|
76
|
+
return err instanceof Error ? err.message : String(err);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printXHelp(log: (message: string) => void): void {
|
|
80
|
+
log(`Usage: agent-swarm x <target> [args]
|
|
81
|
+
|
|
82
|
+
Targets:
|
|
83
|
+
composio Route a request to the Composio REST API
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
agent-swarm x composio GET /tools
|
|
87
|
+
agent-swarm x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printComposioHelp(log: (message: string) => void): void {
|
|
91
|
+
log(`Usage: agent-swarm x composio <method> <path> [options]
|
|
92
|
+
|
|
93
|
+
Routes an HTTP request to the Composio REST API.
|
|
94
|
+
|
|
95
|
+
Arguments:
|
|
96
|
+
<method> GET, POST, PUT, PATCH, DELETE, or HEAD
|
|
97
|
+
<path> API path relative to ${DEFAULT_COMPOSIO_BASE_URL}
|
|
98
|
+
|
|
99
|
+
Options:
|
|
100
|
+
--body, --data <json> JSON request body
|
|
101
|
+
-q, --query k=v Append a query parameter (repeatable)
|
|
102
|
+
-H, --header k=v Add a header (repeatable)
|
|
103
|
+
--base-url <url> Override base URL (default: COMPOSIO_BASE_URL or v3.1 API)
|
|
104
|
+
--org Use COMPOSIO_ORG_API_KEY and x-org-api-key
|
|
105
|
+
--raw Print response text without JSON pretty formatting
|
|
106
|
+
-h, --help Show this help
|
|
107
|
+
|
|
108
|
+
Environment:
|
|
109
|
+
COMPOSIO_API_KEY Project API key for x-api-key auth
|
|
110
|
+
COMPOSIO_ORG_API_KEY Optional organization key for --org
|
|
111
|
+
COMPOSIO_BASE_URL Optional API base URL override
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
agent-swarm x composio GET /tools
|
|
115
|
+
agent-swarm x composio GET /tools --query limit=10
|
|
116
|
+
agent-swarm x composio POST /tool_router/session --body '{"user_id":"swarm"}'
|
|
117
|
+
agent-swarm x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`);
|
|
118
|
+
}
|
package/src/github/handlers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
|
|
1
|
+
import { failTask, findTaskByVcs, getAllAgents, getSwarmConfigs, incrKv, upsertKv } from "../be/db";
|
|
2
2
|
import { findUserByExternalId } from "../be/users";
|
|
3
3
|
import { resolveTemplate } from "../prompts/resolver";
|
|
4
4
|
import { githubContextKey } from "../tasks/context-key";
|
|
@@ -46,6 +46,19 @@ function buildGithubContextKey(
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Runtime-config guards for cancel-on-unassign and cancel-on-review-request-removed.
|
|
51
|
+
* Absent key / any value other than "false" → true (cancel, current behavior).
|
|
52
|
+
* Value "false" → false (skip cancel, leave task untouched).
|
|
53
|
+
*/
|
|
54
|
+
function cancelFlagEnabled(key: string): boolean {
|
|
55
|
+
const row = getSwarmConfigs({ scope: "global", key })[0];
|
|
56
|
+
return row?.value !== "false";
|
|
57
|
+
}
|
|
58
|
+
const cancelOnUnassignEnabled = () => cancelFlagEnabled("github.cancelOnUnassign");
|
|
59
|
+
const cancelOnReviewRequestRemovedEnabled = () =>
|
|
60
|
+
cancelFlagEnabled("github.cancelOnReviewRequestRemoved");
|
|
61
|
+
|
|
49
62
|
/**
|
|
50
63
|
* Get review state emoji and label
|
|
51
64
|
*/
|
|
@@ -278,6 +291,14 @@ export async function handlePullRequest(
|
|
|
278
291
|
return { created: false };
|
|
279
292
|
}
|
|
280
293
|
|
|
294
|
+
// Config gate: skip cancel if disabled
|
|
295
|
+
if (!cancelOnUnassignEnabled()) {
|
|
296
|
+
console.log(
|
|
297
|
+
`[GitHub] unassign cancel disabled by config — leaving task untouched (PR #${pr.number})`,
|
|
298
|
+
);
|
|
299
|
+
return { created: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
// Find the related task
|
|
282
303
|
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
283
304
|
if (!task) {
|
|
@@ -378,6 +399,14 @@ export async function handlePullRequest(
|
|
|
378
399
|
return { created: false };
|
|
379
400
|
}
|
|
380
401
|
|
|
402
|
+
// Config gate: skip cancel if disabled
|
|
403
|
+
if (!cancelOnReviewRequestRemovedEnabled()) {
|
|
404
|
+
console.log(
|
|
405
|
+
`[GitHub] review-request-removed cancel disabled by config — leaving task untouched (PR #${pr.number})`,
|
|
406
|
+
);
|
|
407
|
+
return { created: false };
|
|
408
|
+
}
|
|
409
|
+
|
|
381
410
|
// Find the related task
|
|
382
411
|
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
383
412
|
if (!task) {
|
|
@@ -533,6 +562,7 @@ export async function handlePullRequest(
|
|
|
533
562
|
vcsUrl: pr.html_url,
|
|
534
563
|
vcsInstallationId: installation?.id,
|
|
535
564
|
contextKey: buildGithubContextKey(repository.full_name, "pr", pr.number),
|
|
565
|
+
requestedByUserId,
|
|
536
566
|
});
|
|
537
567
|
|
|
538
568
|
if (lead) {
|
|
@@ -638,6 +668,14 @@ export async function handleIssue(
|
|
|
638
668
|
return { created: false };
|
|
639
669
|
}
|
|
640
670
|
|
|
671
|
+
// Config gate: skip cancel if disabled
|
|
672
|
+
if (!cancelOnUnassignEnabled()) {
|
|
673
|
+
console.log(
|
|
674
|
+
`[GitHub] unassign cancel disabled by config — leaving task untouched (issue #${issue.number})`,
|
|
675
|
+
);
|
|
676
|
+
return { created: false };
|
|
677
|
+
}
|
|
678
|
+
|
|
641
679
|
// Find the related task
|
|
642
680
|
const task = findTaskByVcs(repository.full_name, issue.number);
|
|
643
681
|
if (!task) {
|
|
@@ -771,6 +809,7 @@ export async function handleIssue(
|
|
|
771
809
|
vcsUrl: issue.html_url,
|
|
772
810
|
vcsInstallationId: installation?.id,
|
|
773
811
|
contextKey: buildGithubContextKey(repository.full_name, "issue", issue.number),
|
|
812
|
+
requestedByUserId,
|
|
774
813
|
});
|
|
775
814
|
|
|
776
815
|
if (lead) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
assignUnassignedTaskPending,
|
|
3
|
+
backfillSupersedeTaskResumeTaskId,
|
|
3
4
|
cleanupStaleSessions,
|
|
4
5
|
createTaskExtended,
|
|
5
6
|
deleteActiveSession,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
updateAgentStatus,
|
|
26
27
|
} from "../be/db";
|
|
27
28
|
import { resolveTemplate } from "../prompts/resolver";
|
|
28
|
-
import { createResumeFollowUp } from "../tasks/worker-follow-up";
|
|
29
|
+
import { createResumeFollowUp, getNextResumeGeneration } from "../tasks/worker-follow-up";
|
|
29
30
|
import type { AgentTask } from "../types";
|
|
30
31
|
import { getExecutorRegistry } from "../workflows";
|
|
31
32
|
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
@@ -60,6 +61,11 @@ const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEAN
|
|
|
60
61
|
/** Max pool tasks to auto-assign per sweep */
|
|
61
62
|
const MAX_AUTO_ASSIGN_PER_SWEEP = Number(process.env.HEARTBEAT_MAX_AUTO_ASSIGN) || 5;
|
|
62
63
|
|
|
64
|
+
/** Max crash-recovery resume generations before failing for lead triage */
|
|
65
|
+
export const MAX_RESUME_GENERATIONS = Number(process.env.HEARTBEAT_MAX_RESUME_GENERATIONS) || 3;
|
|
66
|
+
|
|
67
|
+
export const RESUME_BUDGET_EXHAUSTED_REASON = "resume_budget_exhausted";
|
|
68
|
+
|
|
63
69
|
/** Heartbeat checklist interval: how often to check HEARTBEAT.md (default: 30 min) */
|
|
64
70
|
const HEARTBEAT_CHECKLIST_INTERVAL_MS =
|
|
65
71
|
Number(process.env.HEARTBEAT_CHECKLIST_INTERVAL_MS) || 30 * 60 * 1000;
|
|
@@ -98,10 +104,17 @@ export interface HeartbeatFindings {
|
|
|
98
104
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
99
105
|
let checklistInterval: ReturnType<typeof setInterval> | null = null;
|
|
100
106
|
let isSweeping = false;
|
|
107
|
+
let beforeHeartbeatSupersedeForTests: ((task: AgentTask) => void) | null = null;
|
|
101
108
|
|
|
102
109
|
/** Tasks auto-failed during the reboot sweep, consumed by boot triage */
|
|
103
110
|
let rebootAffectedTasks: Array<{ original: AgentTask; retryTaskId: string | null }> = [];
|
|
104
111
|
|
|
112
|
+
export function setBeforeHeartbeatSupersedeForTests(
|
|
113
|
+
hook: ((task: AgentTask) => void) | null,
|
|
114
|
+
): void {
|
|
115
|
+
beforeHeartbeatSupersedeForTests = hook;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
// ============================================================================
|
|
106
119
|
// Tier 1: Preflight Gate
|
|
107
120
|
// ============================================================================
|
|
@@ -300,16 +313,40 @@ function remediateCrashedWorkerTask(
|
|
|
300
313
|
return;
|
|
301
314
|
}
|
|
302
315
|
|
|
303
|
-
|
|
316
|
+
const nextResumeGeneration = getNextResumeGeneration(task);
|
|
317
|
+
if (nextResumeGeneration > MAX_RESUME_GENERATIONS) {
|
|
318
|
+
const failed = failTask(task.id, RESUME_BUDGET_EXHAUSTED_REASON);
|
|
319
|
+
if (failed) {
|
|
320
|
+
findings.autoFailedTasks.push({
|
|
321
|
+
taskId: task.id,
|
|
322
|
+
agentId: task.agentId,
|
|
323
|
+
reason: RESUME_BUDGET_EXHAUSTED_REASON,
|
|
324
|
+
});
|
|
325
|
+
if (opts.cleanupActiveSession) deleteActiveSession(task.id);
|
|
326
|
+
console.warn(
|
|
327
|
+
`[Heartbeat] Auto-failed task ${task.id.slice(0, 8)} — ${RESUME_BUDGET_EXHAUSTED_REASON} (${opts.shortLabel})`,
|
|
328
|
+
);
|
|
329
|
+
const remaining = getActiveTaskCount(task.agentId);
|
|
330
|
+
if (remaining === 0) updateAgentStatus(task.agentId, "idle");
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
beforeHeartbeatSupersedeForTests?.(task);
|
|
336
|
+
|
|
304
337
|
const superseded = supersedeTask(task.id, {
|
|
305
338
|
reason: opts.supersedeReason,
|
|
306
339
|
resumeTaskId: null,
|
|
307
340
|
});
|
|
308
|
-
if (!superseded)
|
|
341
|
+
if (!superseded) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
309
344
|
|
|
310
345
|
const resume = createResumeFollowUp({ parentId: task.id, reason: "crash_recovery" });
|
|
311
346
|
|
|
312
347
|
if (resume.kind === "created") {
|
|
348
|
+
backfillSupersedeTaskResumeTaskId(task.id, resume.task.id);
|
|
349
|
+
|
|
313
350
|
findings.autoResumedTasks.push({
|
|
314
351
|
taskId: task.id,
|
|
315
352
|
resumeTaskId: resume.task.id,
|
|
@@ -320,10 +357,20 @@ function remediateCrashedWorkerTask(
|
|
|
320
357
|
`[Heartbeat] Auto-superseded task ${task.id.slice(0, 8)} — created resume ${resume.task.id.slice(0, 8)} (${opts.shortLabel})`,
|
|
321
358
|
);
|
|
322
359
|
} else {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
360
|
+
const reason =
|
|
361
|
+
resume.kind === "skipped"
|
|
362
|
+
? `resume_creation_skipped_${resume.reason}`
|
|
363
|
+
: "resume_creation_skipped_workflow";
|
|
364
|
+
const failed = failTask(task.id, reason);
|
|
365
|
+
if (failed) {
|
|
366
|
+
findings.autoFailedTasks.push({
|
|
367
|
+
taskId: task.id,
|
|
368
|
+
agentId: task.agentId,
|
|
369
|
+
reason,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
console.warn(
|
|
373
|
+
`[Heartbeat] Task ${task.id.slice(0, 8)} failed because no resume was created (${
|
|
327
374
|
resume.kind === "skipped" ? resume.reason : "workflow-skip"
|
|
328
375
|
})`,
|
|
329
376
|
);
|
|
@@ -461,7 +508,7 @@ function checkWorkerHealth(findings: HeartbeatFindings): void {
|
|
|
461
508
|
|
|
462
509
|
/**
|
|
463
510
|
* Auto-assign unassigned pool tasks to idle workers with capacity.
|
|
464
|
-
*
|
|
511
|
+
* Leaves tasks pending so the assigned worker's normal poll dispatches them.
|
|
465
512
|
*/
|
|
466
513
|
function autoAssignPoolTasks(findings: HeartbeatFindings): void {
|
|
467
514
|
getDb().transaction(() => {
|
|
@@ -472,16 +519,37 @@ function autoAssignPoolTasks(findings: HeartbeatFindings): void {
|
|
|
472
519
|
if (poolTasks.length === 0) return;
|
|
473
520
|
|
|
474
521
|
let workerIndex = 0;
|
|
522
|
+
const reservedByWorker = new Map<string, number>();
|
|
523
|
+
const reservedForWorker = (agentId: string): number => {
|
|
524
|
+
const cached = reservedByWorker.get(agentId);
|
|
525
|
+
if (cached !== undefined) return cached;
|
|
526
|
+
const row = getDb()
|
|
527
|
+
.prepare<{ count: number }, [string]>(
|
|
528
|
+
"SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status IN ('pending', 'in_progress')",
|
|
529
|
+
)
|
|
530
|
+
.get(agentId);
|
|
531
|
+
const reserved = row?.count ?? 0;
|
|
532
|
+
reservedByWorker.set(agentId, reserved);
|
|
533
|
+
return reserved;
|
|
534
|
+
};
|
|
535
|
+
|
|
475
536
|
for (const task of poolTasks) {
|
|
476
537
|
if (workerIndex >= idleWorkers.length) break;
|
|
477
538
|
|
|
478
539
|
const worker = idleWorkers[workerIndex]!;
|
|
479
|
-
const
|
|
540
|
+
const maxTasks = worker.maxTasks ?? 1;
|
|
541
|
+
if (reservedForWorker(worker.id) >= maxTasks) {
|
|
542
|
+
workerIndex++;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const assigned = assignUnassignedTaskPending(task.id, worker.id);
|
|
480
547
|
|
|
481
|
-
if (
|
|
548
|
+
if (assigned) {
|
|
482
549
|
findings.autoAssigned.push({ taskId: task.id, agentId: worker.id });
|
|
550
|
+
reservedByWorker.set(worker.id, reservedForWorker(worker.id) + 1);
|
|
483
551
|
// Check if this worker still has capacity for more
|
|
484
|
-
const remaining =
|
|
552
|
+
const remaining = maxTasks - reservedForWorker(worker.id);
|
|
485
553
|
if (remaining <= 0) {
|
|
486
554
|
workerIndex++;
|
|
487
555
|
}
|