@desplega.ai/agent-swarm 1.89.0 → 1.91.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 +4 -0
- package/openapi.json +74 -1
- package/package.json +6 -6
- package/plugin/skills/composio/SKILL.md +138 -63
- 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/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +28 -0
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/modelsdev-cache.json +752 -81
- package/src/be/scripts/typecheck.ts +132 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
- package/src/be/seed-scripts/index.ts +36 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +91 -1
- package/src/heartbeat/heartbeat.ts +54 -7
- package/src/hooks/hook.ts +32 -9
- package/src/http/index.ts +47 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/scripts.ts +24 -1
- package/src/http/tasks.ts +2 -0
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/providers/claude-adapter.ts +26 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +121 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +227 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/utils/constants.ts +58 -8
- package/templates/skills/swarm-scripts/content.md +46 -7
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildIdentityPayload,
|
|
4
|
+
CLAUDE_MD_PATH,
|
|
5
|
+
collectProfilePayloads,
|
|
6
|
+
extractSetupScriptContent,
|
|
7
|
+
type FileReader,
|
|
8
|
+
IDENTITY_MD_PATH,
|
|
9
|
+
postProfileUpdate,
|
|
10
|
+
resolveClaudeMdPath,
|
|
11
|
+
SETUP_SCRIPT_PATH,
|
|
12
|
+
SOUL_MD_PATH,
|
|
13
|
+
syncProfileFilesToServer,
|
|
14
|
+
TOOLS_MD_PATH,
|
|
15
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
16
|
+
} from "../commands/profile-sync";
|
|
17
|
+
|
|
18
|
+
const MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
19
|
+
const MARKER_END = "# === End agent-managed setup ===";
|
|
20
|
+
|
|
21
|
+
// A SOUL/IDENTITY body long enough to clear the 500-char min-length guard.
|
|
22
|
+
const LONG = "x".repeat(600);
|
|
23
|
+
|
|
24
|
+
describe("extractSetupScriptContent (marker extraction)", () => {
|
|
25
|
+
test("extracts ONLY the content between the agent-managed markers", () => {
|
|
26
|
+
const raw = [
|
|
27
|
+
"#!/bin/bash",
|
|
28
|
+
"echo operator-prelude",
|
|
29
|
+
MARKER_START,
|
|
30
|
+
"export FOO=bar",
|
|
31
|
+
'echo "agent line"',
|
|
32
|
+
MARKER_END,
|
|
33
|
+
"echo operator-postlude",
|
|
34
|
+
].join("\n");
|
|
35
|
+
|
|
36
|
+
expect(extractSetupScriptContent(raw)).toBe('export FOO=bar\necho "agent line"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("strips a leading shebang when no markers are present", () => {
|
|
40
|
+
const raw = '#!/bin/bash\necho "whole file is agent-managed"';
|
|
41
|
+
expect(extractSetupScriptContent(raw)).toBe('echo "whole file is agent-managed"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns null for an empty / whitespace-only file", () => {
|
|
45
|
+
expect(extractSetupScriptContent("")).toBeNull();
|
|
46
|
+
expect(extractSetupScriptContent(" \n\t ")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns null when the marker section is empty", () => {
|
|
50
|
+
const raw = `prelude\n${MARKER_START}\n \n${MARKER_END}\npostlude`;
|
|
51
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null when content exceeds the max length", () => {
|
|
55
|
+
const raw = `${MARKER_START}\n${"a".repeat(65537)}\n${MARKER_END}`;
|
|
56
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("buildIdentityPayload (min-length guard)", () => {
|
|
61
|
+
test("includes SOUL/IDENTITY only when they clear the 500-char minimum", () => {
|
|
62
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
63
|
+
try {
|
|
64
|
+
const ok = buildIdentityPayload({ soulMd: LONG, identityMd: LONG });
|
|
65
|
+
expect(ok.soulMd).toBe(LONG);
|
|
66
|
+
expect(ok.identityMd).toBe(LONG);
|
|
67
|
+
|
|
68
|
+
const short = buildIdentityPayload({ soulMd: "too short", identityMd: "also short" });
|
|
69
|
+
expect(short.soulMd).toBeUndefined();
|
|
70
|
+
expect(short.identityMd).toBeUndefined();
|
|
71
|
+
// The guard must be VISIBLE — it logs why it skipped.
|
|
72
|
+
expect(errSpy).toHaveBeenCalled();
|
|
73
|
+
} finally {
|
|
74
|
+
errSpy.mockRestore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("TOOLS.md has no min-length guard (any non-empty content syncs)", () => {
|
|
79
|
+
const payload = buildIdentityPayload({ toolsMd: "short tools" });
|
|
80
|
+
expect(payload.toolsMd).toBe("short tools");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("HEARTBEAT.md syncs even when empty (no trim/min-length guard)", () => {
|
|
84
|
+
const payload = buildIdentityPayload({ heartbeatMd: "" });
|
|
85
|
+
expect(payload.heartbeatMd).toBe("");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("skips files that exceed the max length", () => {
|
|
89
|
+
const huge = "z".repeat(65537);
|
|
90
|
+
const payload = buildIdentityPayload({ soulMd: huge, toolsMd: huge });
|
|
91
|
+
expect(payload.soulMd).toBeUndefined();
|
|
92
|
+
expect(payload.toolsMd).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("absent files (undefined) produce no keys", () => {
|
|
96
|
+
expect(buildIdentityPayload({})).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("collectProfilePayloads (field gate)", () => {
|
|
101
|
+
const reader = (files: Record<string, string>): FileReader => {
|
|
102
|
+
return async (path: string) => files[path];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test("only the selected field group is collected", async () => {
|
|
106
|
+
const files = reader({
|
|
107
|
+
[SOUL_MD_PATH]: LONG,
|
|
108
|
+
[IDENTITY_MD_PATH]: LONG,
|
|
109
|
+
[TOOLS_MD_PATH]: "tools",
|
|
110
|
+
[CLAUDE_MD_PATH]: "claude md content",
|
|
111
|
+
[SETUP_SCRIPT_PATH]: `${MARKER_START}\nexport X=1\n${MARKER_END}`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const setupOnly = await collectProfilePayloads(["setup"], "session_sync", files);
|
|
115
|
+
expect(setupOnly.map((p) => p.label)).toEqual(["setup"]);
|
|
116
|
+
expect(setupOnly[0]?.body).toEqual({ setupScript: "export X=1", changeSource: "session_sync" });
|
|
117
|
+
|
|
118
|
+
const claudeOnly = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
119
|
+
expect(claudeOnly.map((p) => p.label)).toEqual(["claude"]);
|
|
120
|
+
|
|
121
|
+
const all = await collectProfilePayloads(
|
|
122
|
+
["identity", "claude", "setup"],
|
|
123
|
+
"session_sync",
|
|
124
|
+
files,
|
|
125
|
+
);
|
|
126
|
+
expect(all.map((p) => p.label).sort()).toEqual(["claude", "identity", "setup"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a missing file yields no payload for that group (no empty POST)", async () => {
|
|
130
|
+
const files = reader({}); // nothing on disk
|
|
131
|
+
const payloads = await collectProfilePayloads(
|
|
132
|
+
["identity", "claude", "setup"],
|
|
133
|
+
"session_sync",
|
|
134
|
+
files,
|
|
135
|
+
);
|
|
136
|
+
expect(payloads).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("propagates the changeSource into every body", async () => {
|
|
140
|
+
const files = reader({ [TOOLS_MD_PATH]: "tools" });
|
|
141
|
+
const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
|
|
142
|
+
expect(payloads[0]?.body.changeSource).toBe("self_edit");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("non-Claude providers sync /workspace/CLAUDE.md, not the personal file", async () => {
|
|
146
|
+
// A codex/pi/opencode session edits the runner-materialized workspace file;
|
|
147
|
+
// the Claude personal file (~/.claude/CLAUDE.md) is absent for them.
|
|
148
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "workspace claude md edit" });
|
|
149
|
+
|
|
150
|
+
const payloads = await collectProfilePayloads(
|
|
151
|
+
["claude"],
|
|
152
|
+
"session_sync",
|
|
153
|
+
files,
|
|
154
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
155
|
+
);
|
|
156
|
+
expect(payloads.map((p) => p.label)).toEqual(["claude"]);
|
|
157
|
+
expect(payloads[0]?.body).toEqual({
|
|
158
|
+
claudeMd: "workspace claude md edit",
|
|
159
|
+
changeSource: "session_sync",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Claude's default path never reads the workspace materialization", async () => {
|
|
164
|
+
// Guard against reverting a real Claude personal-file edit: with the default
|
|
165
|
+
// (personal-file) path, content sitting only at /workspace/CLAUDE.md — the
|
|
166
|
+
// stale boot materialization — must NOT be picked up as a claude payload.
|
|
167
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "stale workspace materialization" });
|
|
168
|
+
|
|
169
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
170
|
+
expect(payloads).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("resolveClaudeMdPath (per-batch provider routing)", () => {
|
|
175
|
+
test("an all-Claude batch uses the personal-file path (Stop-hook backstop)", () => {
|
|
176
|
+
expect(resolveClaudeMdPath(["claude"])).toBe(CLAUDE_MD_PATH);
|
|
177
|
+
expect(resolveClaudeMdPath(["claude", "claude"])).toBe(CLAUDE_MD_PATH);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("any non-Claude local session routes to the workspace file", () => {
|
|
181
|
+
expect(resolveClaudeMdPath(["codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
182
|
+
expect(resolveClaudeMdPath(["pi"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
183
|
+
expect(resolveClaudeMdPath(["opencode"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
184
|
+
// Mixed batch: a non-Claude edit means the workspace file is authoritative.
|
|
185
|
+
expect(resolveClaudeMdPath(["claude", "codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
|
|
190
|
+
const opts = {
|
|
191
|
+
agentId: "agent-1",
|
|
192
|
+
apiUrl: "https://api.example.test",
|
|
193
|
+
apiKey: "secret-key",
|
|
194
|
+
};
|
|
195
|
+
const payload = {
|
|
196
|
+
label: "setup",
|
|
197
|
+
body: { setupScript: "export X=1", changeSource: "session_sync" },
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
test("a successful 2xx response logs no warning", async () => {
|
|
201
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
202
|
+
const fetchImpl = (async () => new Response("{}", { status: 200 })) as typeof fetch;
|
|
203
|
+
try {
|
|
204
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
205
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
206
|
+
} finally {
|
|
207
|
+
warnSpy.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a non-2xx response surfaces a warning but does NOT throw", async () => {
|
|
212
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
213
|
+
const fetchImpl = (async () =>
|
|
214
|
+
new Response("boom", { status: 500, statusText: "Server Error" })) as typeof fetch;
|
|
215
|
+
try {
|
|
216
|
+
// Must resolve (non-fatal), not reject.
|
|
217
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
218
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
219
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
220
|
+
expect(msg).toContain("setup sync failed");
|
|
221
|
+
expect(msg).toContain("500");
|
|
222
|
+
} finally {
|
|
223
|
+
warnSpy.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a thrown fetch error surfaces a warning but does NOT throw", async () => {
|
|
228
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
229
|
+
const fetchImpl = (async () => {
|
|
230
|
+
throw new Error("network down");
|
|
231
|
+
}) as typeof fetch;
|
|
232
|
+
try {
|
|
233
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
234
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("setup sync errored");
|
|
236
|
+
} finally {
|
|
237
|
+
warnSpy.mockRestore();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("sends a PUT to the profile route with auth + agent headers", async () => {
|
|
242
|
+
let capturedUrl = "";
|
|
243
|
+
let capturedInit: RequestInit | undefined;
|
|
244
|
+
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
245
|
+
capturedUrl = String(url);
|
|
246
|
+
capturedInit = init;
|
|
247
|
+
return new Response("{}", { status: 200 });
|
|
248
|
+
}) as typeof fetch;
|
|
249
|
+
|
|
250
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
251
|
+
|
|
252
|
+
expect(capturedUrl).toBe("https://api.example.test/api/agents/agent-1/profile");
|
|
253
|
+
expect(capturedInit?.method).toBe("PUT");
|
|
254
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
255
|
+
expect(headers.Authorization).toBe("Bearer secret-key");
|
|
256
|
+
expect(headers["X-Agent-ID"]).toBe("agent-1");
|
|
257
|
+
expect(JSON.parse(String(capturedInit?.body))).toEqual(payload.body);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
262
|
+
test("resolves without throwing even when every POST fails", async () => {
|
|
263
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
264
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
265
|
+
const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
|
266
|
+
try {
|
|
267
|
+
await expect(
|
|
268
|
+
syncProfileFilesToServer({
|
|
269
|
+
agentId: "agent-1",
|
|
270
|
+
apiUrl: "https://api.example.test",
|
|
271
|
+
apiKey: "secret-key",
|
|
272
|
+
changeSource: "session_sync",
|
|
273
|
+
// No files on a CI box → typically no payloads; still must never throw.
|
|
274
|
+
fetchImpl,
|
|
275
|
+
}),
|
|
276
|
+
).resolves.toBeUndefined();
|
|
277
|
+
} finally {
|
|
278
|
+
warnSpy.mockRestore();
|
|
279
|
+
errSpy.mockRestore();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -288,6 +288,39 @@ describe("runScript", () => {
|
|
|
288
288
|
}
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
test("zod import works in compiled binary mode (SCRIPT_RUNTIME_DIR)", async () => {
|
|
292
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-runtime-test-${crypto.randomUUID()}`;
|
|
293
|
+
await Bun.$`mkdir -p ${tmpdir}`;
|
|
294
|
+
try {
|
|
295
|
+
const runtimeSrc = new URL("../scripts-runtime", import.meta.url).pathname;
|
|
296
|
+
await Bun.$`bun build ${runtimeSrc}/eval-harness.ts --target bun --no-splitting --outfile ${tmpdir}/eval-harness.bundle.js`.quiet();
|
|
297
|
+
await Bun.$`bun build ${runtimeSrc}/stdlib/index.ts --target bun --no-splitting --outfile ${tmpdir}/stdlib.bundle.js`.quiet();
|
|
298
|
+
await Bun.$`bun build ${runtimeSrc}/swarm-sdk.ts --target bun --no-splitting --outfile ${tmpdir}/swarm-sdk.bundle.js`.quiet();
|
|
299
|
+
const zodEntry = Bun.resolveSync("zod", import.meta.dir);
|
|
300
|
+
await Bun.$`bun build ${zodEntry} --target bun --no-splitting --outfile ${tmpdir}/zod.bundle.js`.quiet();
|
|
301
|
+
|
|
302
|
+
process.env.SCRIPT_RUNTIME_DIR = tmpdir;
|
|
303
|
+
|
|
304
|
+
const output = await runScript({
|
|
305
|
+
agentId: "agent-1",
|
|
306
|
+
args: { name: "test" },
|
|
307
|
+
resources,
|
|
308
|
+
source: `
|
|
309
|
+
import { z } from "zod";
|
|
310
|
+
export const argsSchema = z.object({ name: z.string() });
|
|
311
|
+
export default async (args: z.infer<typeof argsSchema>) => ({ greeting: "hello " + args.name });
|
|
312
|
+
`,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(output.error).toBeUndefined();
|
|
316
|
+
expect(output.result).toEqual({ greeting: "hello test" });
|
|
317
|
+
expect(output.exitCode).toBe(0);
|
|
318
|
+
} finally {
|
|
319
|
+
delete process.env.SCRIPT_RUNTIME_DIR;
|
|
320
|
+
await Bun.$`rm -rf ${tmpdir}`;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
291
324
|
test("argsSchema rejects invalid args with a formatted Zod error", async () => {
|
|
292
325
|
const output = await runScript({
|
|
293
326
|
agentId: "agent-1",
|
|
@@ -48,8 +48,8 @@ afterAll(async () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
describe("seed-scripts catalog", () => {
|
|
51
|
-
test("manifest holds
|
|
52
|
-
expect(SEED_SCRIPTS.length).toBe(
|
|
51
|
+
test("manifest holds 14 unique, well-described scripts", () => {
|
|
52
|
+
expect(SEED_SCRIPTS.length).toBe(14);
|
|
53
53
|
const names = SEED_SCRIPTS.map((s) => s.name);
|
|
54
54
|
expect(new Set(names).size).toBe(names.length);
|
|
55
55
|
for (const s of SEED_SCRIPTS) {
|
|
@@ -5,6 +5,7 @@ import { assertSelectOnlyQuery } from "@/http/db-query";
|
|
|
5
5
|
import { snapshotMetric } from "@/metrics/version";
|
|
6
6
|
import { createToolRegistrar } from "@/tools/utils";
|
|
7
7
|
import { MetricDefinitionSchema } from "@/types";
|
|
8
|
+
import { getAppUrl } from "@/utils/constants";
|
|
8
9
|
|
|
9
10
|
function slugify(input: string): string {
|
|
10
11
|
const slug = input
|
|
@@ -16,9 +17,7 @@ function slugify(input: string): string {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function getAppBaseUrl(): string {
|
|
19
|
-
|
|
20
|
-
if (env) return env.replace(/\/+$/, "");
|
|
21
|
-
return "http://localhost:5274";
|
|
20
|
+
return getAppUrl();
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
function metricEditCounter(metricId: string): number {
|
package/src/tools/create-page.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { createPage, getPage, getPageBySlug, getPageVersions, updatePage } from
|
|
|
23
23
|
import { snapshotPage } from "@/pages/version";
|
|
24
24
|
import { createToolRegistrar } from "@/tools/utils";
|
|
25
25
|
import { PageAuthModeSchema, PageContentTypeSchema } from "@/types";
|
|
26
|
+
import { getAppUrl, getPublicMcpBaseUrl } from "@/utils/constants";
|
|
26
27
|
|
|
27
28
|
/** Same slugifier used by the HTTP createPage handler. */
|
|
28
29
|
function slugify(input: string): string {
|
|
@@ -35,15 +36,11 @@ function slugify(input: string): string {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function getApiBaseUrl(): string {
|
|
38
|
-
|
|
39
|
-
if (env) return env.replace(/\/+$/, "");
|
|
40
|
-
return `http://localhost:${process.env.PORT || "3013"}`;
|
|
39
|
+
return getPublicMcpBaseUrl();
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
function getAppBaseUrl(): string {
|
|
44
|
-
|
|
45
|
-
if (env) return env.replace(/\/+$/, "");
|
|
46
|
-
return "http://localhost:5274";
|
|
43
|
+
return getAppUrl();
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
/**
|
package/src/tools/memory-rate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as z from "zod";
|
|
|
3
3
|
import { REFERENCES_SOURCE_MAX_LENGTH, sanitizeReferencesSource } from "@/be/memory/raters/types";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
5
|
import { getApiKey } from "@/utils/api-key";
|
|
6
|
+
import { getMcpBaseUrl } from "@/utils/constants";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-5.md §1
|
|
@@ -92,7 +93,7 @@ export const registerMemoryRateTool = (server: McpServer) => {
|
|
|
92
93
|
cleanedReferencesSource = cleaned;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
const apiUrl =
|
|
96
|
+
const apiUrl = getMcpBaseUrl();
|
|
96
97
|
const apiKey = getApiKey();
|
|
97
98
|
|
|
98
99
|
const event = {
|
|
@@ -10,13 +10,11 @@ import {
|
|
|
10
10
|
putKapsoNumberMapping,
|
|
11
11
|
} from "@/integrations/kapso/config";
|
|
12
12
|
import { createToolRegistrar } from "@/tools/utils";
|
|
13
|
+
import { getPublicMcpBaseUrl } from "@/utils/constants";
|
|
13
14
|
|
|
14
15
|
/** Build the native inbound webhook URL the swarm exposes for Kapso deliveries. */
|
|
15
16
|
function nativeWebhookUrl(): string {
|
|
16
|
-
|
|
17
|
-
process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`
|
|
18
|
-
).replace(/\/+$/, "");
|
|
19
|
-
return `${base}/api/integrations/kapso/webhook`;
|
|
17
|
+
return `${getPublicMcpBaseUrl()}/api/integrations/kapso/webhook`;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
export const registerRegisterKapsoNumberTool = (server: McpServer) => {
|
|
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { getAppUrl } from "@/utils/constants";
|
|
5
6
|
|
|
6
7
|
const QuestionSchema = z.object({
|
|
7
8
|
id: z.string().describe("Unique ID for the question (used as key in responses)"),
|
|
@@ -94,7 +95,7 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
|
|
|
94
95
|
timeoutSeconds,
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
const appUrl =
|
|
98
|
+
const appUrl = getAppUrl();
|
|
98
99
|
const url = `${appUrl}/approval-requests/${request.id}`;
|
|
99
100
|
|
|
100
101
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { getApiKey } from "@/utils/api-key";
|
|
4
|
+
import { getMcpBaseUrl } from "@/utils/constants";
|
|
4
5
|
import type { RequestInfo } from "./utils";
|
|
5
6
|
|
|
6
7
|
export const SCRIPT_TRANSPORT_ERROR =
|
|
@@ -20,10 +21,7 @@ export const scriptToolOutputSchema = z.object({
|
|
|
20
21
|
export type ScriptToolStructuredContent = z.infer<typeof scriptToolOutputSchema>;
|
|
21
22
|
|
|
22
23
|
function apiBaseUrl(): string {
|
|
23
|
-
return (
|
|
24
|
-
/\/+$/,
|
|
25
|
-
"",
|
|
26
|
-
);
|
|
24
|
+
return getMcpBaseUrl();
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
function toolError(message: string, status = 400): CallToolResult {
|
package/src/tools/script-run.ts
CHANGED
|
@@ -28,6 +28,13 @@ export const registerScriptRunTool = (server: McpServer) => {
|
|
|
28
28
|
fsMode: scriptFsModeSchema
|
|
29
29
|
.default("none")
|
|
30
30
|
.describe("Filesystem mode. v1 supports none only."),
|
|
31
|
+
idempotencyKey: z
|
|
32
|
+
.string()
|
|
33
|
+
.max(200)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(
|
|
36
|
+
"When set, output is auto-persisted to kv under script:executions/{key}. Re-running with the same key overwrites. Queryable via kv-get.",
|
|
37
|
+
),
|
|
31
38
|
}),
|
|
32
39
|
outputSchema: scriptToolOutputSchema,
|
|
33
40
|
},
|
package/src/utils/constants.ts
CHANGED
|
@@ -3,19 +3,69 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Default dashboard URL used when `APP_URL`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Default dashboard URL used when neither `APP_URL` nor the deprecated
|
|
7
|
+
* `DASHBOARD_URL` is set. Points at the public production dashboard so links
|
|
8
|
+
* (Slack messages, approval URLs, page share links, post-OAuth redirects)
|
|
9
|
+
* stay renderable even when an operator forgets to configure it. Local dev
|
|
10
|
+
* should set `APP_URL` (e.g. in `.env`) to point at the local dashboard.
|
|
9
11
|
*/
|
|
10
12
|
export const DEFAULT_APP_URL = "https://app.agent-swarm.dev";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Resolve
|
|
14
|
-
*
|
|
15
|
+
* Resolve every explicitly configured app/dashboard URL. Each env var may be
|
|
16
|
+
* a comma-separated origin list; entries are returned in precedence order with
|
|
17
|
+
* trailing slashes stripped.
|
|
18
|
+
*
|
|
19
|
+
* Precedence: `APP_URL` entries → `DASHBOARD_URL` entries (deprecated alias,
|
|
20
|
+
* kept for back-compat).
|
|
21
|
+
*/
|
|
22
|
+
export function getConfiguredAppUrls(): string[] {
|
|
23
|
+
return [process.env.APP_URL, process.env.DASHBOARD_URL]
|
|
24
|
+
.flatMap((value) => (value ?? "").split(","))
|
|
25
|
+
.map((value) => value.trim().replace(/\/+$/, ""))
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the effective app/dashboard URL — the public origin the user's
|
|
31
|
+
* browser is sent to (post-login redirects, Slack/approval links, page
|
|
32
|
+
* `app_url` share links). Trailing slashes are stripped.
|
|
33
|
+
*
|
|
34
|
+
* Precedence: first configured `APP_URL` entry → first configured
|
|
35
|
+
* `DASHBOARD_URL` entry (deprecated alias, kept for back-compat) → fallback.
|
|
36
|
+
* This is the single source of truth; call sites must not re-read
|
|
37
|
+
* `APP_URL`/`DASHBOARD_URL` directly.
|
|
38
|
+
*/
|
|
39
|
+
export function getAppUrl(fallback = DEFAULT_APP_URL): string {
|
|
40
|
+
return (getConfiguredAppUrls()[0] || fallback).replace(/\/+$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Internal API/MCP base URL — how workers/agents and in-process callers reach
|
|
45
|
+
* the API server. May be a private/cluster address (e.g. the Helm ClusterIP
|
|
46
|
+
* `http://<release>-api:3013`). Do NOT use for browser-facing or
|
|
47
|
+
* externally-registered URLs (OAuth redirect URIs, webhook URLs): those must
|
|
48
|
+
* resolve to a host the browser / third party can reach — use
|
|
49
|
+
* {@link getPublicMcpBaseUrl} (no request context) or `deriveApiBaseUrl(req)`
|
|
50
|
+
* (request-scoped) instead. Trailing slashes are stripped.
|
|
51
|
+
*/
|
|
52
|
+
export function getMcpBaseUrl(): string {
|
|
53
|
+
const raw = process.env.MCP_BASE_URL?.trim();
|
|
54
|
+
return (raw || `http://localhost:${process.env.PORT || "3013"}`).replace(/\/+$/, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Public, browser-/externally-reachable origin of the API server — where
|
|
59
|
+
* `/api/mcp-oauth/callback`, OAuth redirect URIs, and registered webhook URLs
|
|
60
|
+
* resolve. Falls back to {@link getMcpBaseUrl} when the public and internal
|
|
61
|
+
* hosts are the same (local dev, single-box, or an ngrok/tunnel set as
|
|
62
|
+
* `MCP_BASE_URL`). In split deployments (Helm), set `PUBLIC_MCP_BASE_URL` to
|
|
63
|
+
* the public ingress URL while `MCP_BASE_URL` stays the internal service
|
|
64
|
+
* address. Trailing slashes are stripped.
|
|
15
65
|
*/
|
|
16
|
-
export function
|
|
17
|
-
const raw = process.env.
|
|
18
|
-
return
|
|
66
|
+
export function getPublicMcpBaseUrl(): string {
|
|
67
|
+
const raw = process.env.PUBLIC_MCP_BASE_URL?.trim();
|
|
68
|
+
return raw ? raw.replace(/\/+$/, "") : getMcpBaseUrl();
|
|
19
69
|
}
|
|
20
70
|
|
|
21
71
|
/**
|
|
@@ -32,16 +32,18 @@ Use `script-query-types` before non-trivial work so the script matches the live
|
|
|
32
32
|
Use `script-run` with inline source for one-off work:
|
|
33
33
|
|
|
34
34
|
```typescript
|
|
35
|
-
export default async function main(args:
|
|
35
|
+
export default async function main(args: any, ctx: any) {
|
|
36
36
|
const { swarm, logger } = ctx;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// All SDK methods return Promise<unknown> — unwrap defensively.
|
|
38
|
+
const res: any = await swarm.task_list({ status: args?.status, limit: args?.limit ?? 50 });
|
|
39
|
+
const tasks: any[] = res?.data?.tasks ?? res?.tasks ?? [];
|
|
40
|
+
logger.info(`Fetched ${tasks.length} tasks`);
|
|
39
41
|
return {
|
|
40
|
-
total:
|
|
41
|
-
tasks:
|
|
42
|
+
total: tasks.length,
|
|
43
|
+
tasks: tasks.map((task: any) => ({
|
|
42
44
|
id: task.id,
|
|
43
45
|
status: task.status,
|
|
44
|
-
title: task.task
|
|
46
|
+
title: task.task?.slice(0, 120),
|
|
45
47
|
})),
|
|
46
48
|
};
|
|
47
49
|
}
|
|
@@ -60,8 +62,45 @@ Good named scripts:
|
|
|
60
62
|
- Fan out over many swarm tasks, memories, repos, or schedules.
|
|
61
63
|
- Convert noisy JSON or HTML into a compact summary.
|
|
62
64
|
|
|
65
|
+
## Using `db_query` For Aggregation
|
|
66
|
+
|
|
67
|
+
For scripts that aggregate over tasks, sessions, or memory, `ctx.swarm.db_query` with direct SQL is far more efficient than fetching lists client-side.
|
|
68
|
+
|
|
69
|
+
**The parameter is `sql`, not `query`:**
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// CORRECT
|
|
73
|
+
const res = await ctx.swarm.db_query({ sql: "SELECT status, count(*) as cnt FROM agent_tasks GROUP BY status" });
|
|
74
|
+
|
|
75
|
+
// WRONG — silently returns no data
|
|
76
|
+
const res = await ctx.swarm.db_query({ query: "SELECT ..." });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**`db_query` returns positional rows, not objects.** The response shape is `{ rows: unknown[][], columns: string[] }`. Zip them into objects:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
function rowsToObjects(res: any): any[] {
|
|
83
|
+
const p = res?.data ?? res;
|
|
84
|
+
const cols: string[] = p?.columns ?? [];
|
|
85
|
+
return (p?.rows ?? []).map((r: any) =>
|
|
86
|
+
Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rows = rowsToObjects(await ctx.swarm.db_query({
|
|
91
|
+
sql: `SELECT status, count(*) as cnt FROM agent_tasks WHERE createdAt > datetime('now','-3 days') GROUP BY status`,
|
|
92
|
+
}));
|
|
93
|
+
// rows = [{ status: "completed", cnt: 42 }, ...]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Common tables:** `agent_tasks` (tasks), `session_logs` (tool call logs), `agent_memory` (memories), `scheduled_tasks` (schedules), `agents` (agent registry).
|
|
97
|
+
|
|
98
|
+
**`session_logs` has no `tool_name` column.** Tool names are embedded in the `content` JSON column. Extract them SQL-side with `instr`/`substr` or parse JSON in JS after fetching.
|
|
99
|
+
|
|
63
100
|
## SDK And Context Gotchas
|
|
64
101
|
|
|
102
|
+
- **`args` can be undefined.** When a script is called without arguments, `args` is `undefined`. Always guard: `argsSchema.safeParse(args || {})` or use optional chaining (`args?.field`).
|
|
103
|
+
- **All SDK methods return `Promise<unknown>`.** Never assume a specific return shape without defensive unwrapping (`res?.data?.tasks ?? res?.tasks ?? []`). Run `script-query-types` to see live type signatures — return types are `unknown` and actual shapes vary by endpoint.
|
|
65
104
|
- `agentId` is propagated to scripts via the `X-Agent-ID` header, so SDK calls run as the invoking agent.
|
|
66
105
|
- `taskId` is not ambient. If a script needs to call `ctx.swarm.task_storeProgress`, pass `taskId` explicitly in `args`.
|
|
67
106
|
- Scripts invoked from a workflow script node may run with a workflow identity rather than a human or worker agent identity.
|
|
@@ -73,7 +112,7 @@ Good named scripts:
|
|
|
73
112
|
Thread task identity explicitly:
|
|
74
113
|
|
|
75
114
|
```typescript
|
|
76
|
-
export default async function main(args: { taskId: string; items: string[] }, ctx) {
|
|
115
|
+
export default async function main(args: { taskId: string; items: string[] }, ctx: any) {
|
|
77
116
|
const { swarm } = ctx;
|
|
78
117
|
await swarm.task_storeProgress({
|
|
79
118
|
taskId: args.taskId,
|