@desplega.ai/agent-swarm 1.123.1 → 1.124.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 +2 -1
- package/dist/{actions-3ntn8s3f.js → actions-z3d0fk7z.js} +5 -5
- package/dist/{app-pej7g57x.js → app-q2rc9tdr.js} +3 -3
- package/dist/{assistant-pxa36zjn.js → assistant-3stjq7fz.js} +8 -8
- package/dist/{boot-reembed-wdwde4vr.js → boot-reembed-5867whnb.js} +3 -3
- package/dist/{boot-reembed-ybq960bh.js → boot-reembed-v6r1df32.js} +4 -4
- package/dist/{boot-scrub-logs-ndqxg6e3.js → boot-scrub-logs-8b8jegk3.js} +2 -2
- package/dist/{cli-xv09zc8e.js → cli-0925phzv.js} +1 -1
- package/dist/{cli-256kb8p5.js → cli-2yr6edr7.js} +4 -4
- package/dist/{cli-x2hk4m81.js → cli-3z4q09tn.js} +54 -9
- package/dist/{cli-7yn0n4fp.js → cli-5jn6wnd9.js} +8 -8
- package/dist/{cli-82eq5zf5.js → cli-92bqgbgy.js} +1 -1
- package/dist/{cli-r07fhvsk.js → cli-dq2ssyf0.js} +5 -5
- package/dist/{cli-kqgkfpz7.js → cli-gkvf8d83.js} +1 -1
- package/dist/{cli-7qvpf14x.js → cli-gzjsamwc.js} +1 -1
- package/dist/{cli-333x50bh.js → cli-h89g0qrd.js} +1 -1
- package/dist/{cli-g4bfr1e7.js → cli-jp4mdj9d.js} +2 -2
- package/dist/{cli-2e5t0yjj.js → cli-kk58tg33.js} +7 -7
- package/dist/{cli-mb3gz7qr.js → cli-mcez7z4p.js} +1 -1
- package/dist/{cli-ca32n52j.js → cli-mmk5vagg.js} +2 -2
- package/dist/{cli-1kv5sydm.js → cli-p0ec4vv6.js} +1 -1
- package/dist/{cli-zvbxe7pf.js → cli-p0zgv8pj.js} +1 -1
- package/dist/{cli-mntyk3cr.js → cli-perb20ma.js} +1 -1
- package/dist/{cli-7rk0bbfr.js → cli-qf7acvyr.js} +2 -2
- package/dist/{cli-1cah98w1.js → cli-qwsagsmg.js} +1 -1
- package/dist/{cli-pjvdm39a.js → cli-sqv6c9b8.js} +5 -5
- package/dist/{cli-vw0acwe7.js → cli-vm20sh8d.js} +3 -3
- package/dist/{cli-sj43m5z0.js → cli-wn3m6q54.js} +3 -3
- package/dist/{cli-hgkf9zmd.js → cli-x268say8.js} +125 -58
- package/dist/{cli-0pmkqg1d.js → cli-y43n987v.js} +1 -1
- package/dist/{cli-qrnhnhma.js → cli-ykthjw29.js} +4 -4
- package/dist/{cli-k3yfwe8g.js → cli-zxb72ctk.js} +2 -2
- package/dist/cli.js +9 -9
- package/dist/{commands-5p0erxye.js → commands-sda76yn8.js} +2 -2
- package/dist/{db-yp9e9nvs.js → db-vn9e4rqj.js} +6 -2
- package/dist/{handlers-3hqw1dsr.js → handlers-xfja18ac.js} +8 -8
- package/dist/{hook-5981d59r.js → hook-2medrp47.js} +1 -1
- package/dist/{http-ek9ctgth.js → http-937pyjfc.js} +49 -39
- package/dist/{index-33eb3q08.js → index-2bqd2q9v.js} +11 -11
- package/dist/{index-8j9b86c9.js → index-gw4en7jg.js} +9 -9
- package/dist/{index-chcty242.js → index-knqczmnm.js} +10 -10
- package/dist/{index-1cnvzmh4.js → index-vah60vxp.js} +8 -8
- package/dist/{keepalive-rrvxy1fy.js → keepalive-20rd6hxh.js} +5 -5
- package/dist/{lead-jvw8b413.js → lead-7w2k3a49.js} +20 -20
- package/dist/{maintenance-3zjnr2wa.js → maintenance-p3x918yw.js} +4 -4
- package/dist/{oauth-refresh-sweep-vfhvqmby.js → oauth-refresh-sweep-shm0nzm4.js} +4 -4
- package/dist/{onboard-kvntqz8h.js → onboard-py9dnf54.js} +2 -2
- package/dist/{otel-impl-61tsn4bx.js → otel-impl-7yjkfg68.js} +1 -1
- package/dist/{pricing-refresh-jcp6ntbk.js → pricing-refresh-3snb6370.js} +4 -4
- package/dist/{rbac-roles-xcbab18x.js → rbac-roles-5e72bbpk.js} +3 -3
- package/dist/{rbac-roles-r3j4z9vs.js → rbac-roles-gqxdp8st.js} +4 -4
- package/dist/{seed-pricing-bd669a7j.js → seed-pricing-bg42d7h2.js} +3 -3
- package/dist/{setup-pg03xran.js → setup-nzk3kbbq.js} +2 -2
- package/dist/{worker-q5n4qwvq.js → worker-0p1jdzkm.js} +20 -20
- package/openapi.json +23 -1
- package/package.json +1 -1
- package/src/be/db.ts +81 -4
- package/src/be/scripts/typecheck.ts +8 -4
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +1 -1
- package/src/http/workflows.ts +16 -4
- package/src/prompts/session-templates.ts +6 -3
- package/src/scripts-runtime/swarm-sdk.ts +10 -0
- package/src/scripts-runtime/types/stdlib.d.ts +4 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
- package/src/telemetry.ts +10 -7
- package/src/tests/mcp-tools.test.ts +2 -2
- package/src/tests/prompt-template-session.test.ts +7 -0
- package/src/tests/scripts-mcp-e2e.test.ts +51 -2
- package/src/tests/scripts-typecheck.test.ts +17 -0
- package/src/tests/sdk-allowlist.test.ts +34 -1
- package/src/tests/seed-scripts.test.ts +43 -0
- package/src/tests/swarm-tool-result-gate.test.ts +35 -0
- package/src/tests/task-tools-ctx.test.ts +29 -0
- package/src/tests/telemetry-init.test.ts +21 -24
- package/src/tests/workflow-http-v2.test.ts +116 -0
- package/src/tools/get-tasks.ts +40 -1
- package/src/tools/script-run.ts +2 -2
- package/src/tools/script-upsert.ts +2 -2
- package/src/tools/utils.ts +14 -8
- package/src/tools/workflows/list-workflow-runs.ts +95 -24
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
mcpToolNameForSdkMethod,
|
|
10
10
|
SDK_ALLOWLIST,
|
|
11
11
|
} from "../scripts-runtime/sdk-allowlist";
|
|
12
|
-
import
|
|
12
|
+
import { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
13
13
|
import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
|
|
14
14
|
import { createServer } from "../server";
|
|
15
15
|
|
|
@@ -65,6 +65,39 @@ describe("script SDK allowlist", () => {
|
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("workflow_listRuns sends bounded pagination defaults and rejects an excessive limit", async () => {
|
|
69
|
+
let requestUrl: URL | undefined;
|
|
70
|
+
const httpServer = Bun.serve({
|
|
71
|
+
port: 0,
|
|
72
|
+
fetch(req) {
|
|
73
|
+
requestUrl = new URL(req.url);
|
|
74
|
+
return Response.json({ runs: [], page: { limit: 20, offset: 0, total: 0 } });
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const config = new SwarmConfig({
|
|
78
|
+
system: {
|
|
79
|
+
apiKey: { value: "sdk-test-key", isSecret: true },
|
|
80
|
+
agentId: { value: "sdk-test-agent", isSecret: false },
|
|
81
|
+
mcpBaseUrl: { value: `http://127.0.0.1:${httpServer.port}`, isSecret: false },
|
|
82
|
+
},
|
|
83
|
+
user: {},
|
|
84
|
+
});
|
|
85
|
+
const sdk = createSwarmSdk(config);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await sdk.workflow_listRuns({ workflowId: "workflow-1" });
|
|
89
|
+
expect(requestUrl?.pathname).toBe("/api/workflows/workflow-1/runs");
|
|
90
|
+
expect(requestUrl?.searchParams.get("limit")).toBe("20");
|
|
91
|
+
expect(requestUrl?.searchParams.get("offset")).toBe("0");
|
|
92
|
+
|
|
93
|
+
await expect(sdk.workflow_listRuns({ workflowId: "workflow-1", limit: 101 })).rejects.toThrow(
|
|
94
|
+
"between 1 and 100",
|
|
95
|
+
);
|
|
96
|
+
} finally {
|
|
97
|
+
httpServer.stop(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
68
101
|
test("bundled swarm-sdk.d.ts exposes only allowlisted methods", async () => {
|
|
69
102
|
const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
|
|
70
103
|
for (const name of SDK_ALLOWLIST) {
|
|
@@ -14,6 +14,7 @@ import compoundInsights from "../be/seed-scripts/catalog/compound-insights";
|
|
|
14
14
|
import opsCatalogAudit, {
|
|
15
15
|
renderPage as renderOpsCatalogAuditPage,
|
|
16
16
|
} from "../be/seed-scripts/catalog/ops-catalog-audit";
|
|
17
|
+
import taskContextGathering from "../be/seed-scripts/catalog/task-context-gathering";
|
|
17
18
|
import { extractScriptSignature } from "../scripts-runtime/extract-signature";
|
|
18
19
|
import { validateScriptImports } from "../scripts-runtime/import-allowlist";
|
|
19
20
|
|
|
@@ -97,6 +98,48 @@ describe("seed-scripts catalog", () => {
|
|
|
97
98
|
}
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
test("task-context-gathering unwraps the direct REST task response", async () => {
|
|
102
|
+
const taskId = "task-context-test";
|
|
103
|
+
const result = await taskContextGathering(
|
|
104
|
+
{ taskId, queries: ["task context"] },
|
|
105
|
+
{
|
|
106
|
+
swarm: {
|
|
107
|
+
async task_get(args: { taskId: string }) {
|
|
108
|
+
expect(args.taskId).toBe(taskId);
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
status: 200,
|
|
112
|
+
data: {
|
|
113
|
+
id: taskId,
|
|
114
|
+
status: "in_progress",
|
|
115
|
+
task: "Restore the flattened task response consumer",
|
|
116
|
+
dependsOn: ["parent-task"],
|
|
117
|
+
agentId: "agent-test",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
async memory_search() {
|
|
122
|
+
return { success: true, status: 200, data: { results: [] } };
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(result.task).toEqual({
|
|
129
|
+
id: taskId,
|
|
130
|
+
status: "in_progress",
|
|
131
|
+
description: "Restore the flattened task response consumer",
|
|
132
|
+
dependsOn: ["parent-task"],
|
|
133
|
+
slackChannelId: undefined,
|
|
134
|
+
slackThreadTs: undefined,
|
|
135
|
+
createdAt: undefined,
|
|
136
|
+
finishedAt: undefined,
|
|
137
|
+
agentId: "agent-test",
|
|
138
|
+
output: undefined,
|
|
139
|
+
failureReason: undefined,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
100
143
|
test("scriptsSeeder declares the script kind and one item per catalog entry", async () => {
|
|
101
144
|
expect(scriptsSeeder.kind).toBe("script");
|
|
102
145
|
const items = await scriptsSeeder.items();
|
|
@@ -214,6 +214,41 @@ describe("finalizeSwarmToolResult", () => {
|
|
|
214
214
|
expect(text).toContain("[truncated");
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
test("explicit details are normalized and capped identically in both channels", () => {
|
|
218
|
+
const blob = "x".repeat(50_000);
|
|
219
|
+
const result = finalizeSwarmToolResult("some-tool", {
|
|
220
|
+
ok: true,
|
|
221
|
+
message: "Big rendered payload.",
|
|
222
|
+
details: ` ${blob} `,
|
|
223
|
+
data: { blob },
|
|
224
|
+
});
|
|
225
|
+
const text = (result.content?.[0] as { text: string }).text;
|
|
226
|
+
const structured = result.structuredContent as {
|
|
227
|
+
details?: string;
|
|
228
|
+
blob?: string;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
expect(structured.details).toContain("[truncated");
|
|
232
|
+
expect(structured.details!.length).toBeLessThan(10_000);
|
|
233
|
+
expect(text).toBe(`Big rendered payload.\n\n${structured.details}`);
|
|
234
|
+
// Only the rendered details are bounded; callers using structured data
|
|
235
|
+
// still receive the complete payload.
|
|
236
|
+
expect(structured.blob).toBe(blob);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("whitespace-only details fall back to a rendered data preview", () => {
|
|
240
|
+
const result = finalizeSwarmToolResult("some-tool", {
|
|
241
|
+
ok: true,
|
|
242
|
+
message: "Saved.",
|
|
243
|
+
details: " \n\t ",
|
|
244
|
+
data: { taskId: "t-whitespace" },
|
|
245
|
+
});
|
|
246
|
+
const text = (result.content?.[0] as { text: string }).text;
|
|
247
|
+
|
|
248
|
+
expect(text).toContain('"taskId": "t-whitespace"');
|
|
249
|
+
expect((result.structuredContent as { details?: string }).details).toBeUndefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
217
252
|
test("an explicit tool-provided nudge wins over the central map", () => {
|
|
218
253
|
const result = finalizeSwarmToolResult("script-run", {
|
|
219
254
|
ok: false,
|
|
@@ -74,6 +74,35 @@ describe("task tool ctx", () => {
|
|
|
74
74
|
expect(data.tasks.every((task) => task.task?.startsWith("owned task"))).toBe(true);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("getTasksHandler renders compact escaped markdown without changing structured tasks", async () => {
|
|
78
|
+
const user = createUser({ name: "Markdown List User" });
|
|
79
|
+
const taskText = "triage | path\\one\nsecond line";
|
|
80
|
+
const task = createTaskExtended(taskText, { requestedByUserId: user.id });
|
|
81
|
+
|
|
82
|
+
const result = await getTasksHandler(userCtx(user), {
|
|
83
|
+
includeFull: true,
|
|
84
|
+
includeHeartbeat: true,
|
|
85
|
+
limit: 50,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.details).toContain("| ID | Status | Priority | Agent | Task |");
|
|
89
|
+
expect(result.details).toContain("triage \\| path\\\\one<br>second line");
|
|
90
|
+
expect(result.details).not.toContain('"tasks":');
|
|
91
|
+
const data = result.data as { tasks: Array<{ id: string; task?: string }> };
|
|
92
|
+
expect(data.tasks.find((row) => row.id === task.id)?.task).toBe(taskText);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("getTasksHandler uses a human empty-state instead of a raw data fallback", async () => {
|
|
96
|
+
const user = createUser({ name: "Empty List User" });
|
|
97
|
+
const result = await getTasksHandler(userCtx(user), {
|
|
98
|
+
includeHeartbeat: true,
|
|
99
|
+
limit: 50,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.details).toBe("No tasks matched the current filters.");
|
|
103
|
+
expect((result.data as { tasks: unknown[] }).tasks).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
77
106
|
test("assertOwnsTask gates user tasks and allows owned or owner ctx", () => {
|
|
78
107
|
const owner = createUser({ name: "Task Owner" });
|
|
79
108
|
const foreignUser = createUser({ name: "Foreign User" });
|
|
@@ -122,44 +122,25 @@ describe("initTelemetry", () => {
|
|
|
122
122
|
expect(_getInstalledAtForTests()).toBeNull();
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
test("
|
|
126
|
-
const existing = "
|
|
125
|
+
test("existing installationId, no stored anchor → installedAt omitted, no backfill write attempted (even with generateIfMissing)", async () => {
|
|
126
|
+
const existing = "install_noanchor";
|
|
127
127
|
const writes: Array<{ key: string; value: string }> = [];
|
|
128
128
|
await initTelemetry(
|
|
129
129
|
"api-server",
|
|
130
130
|
async (key) => (key === "telemetry_installation_id" ? existing : undefined),
|
|
131
131
|
async (key, value) => {
|
|
132
|
-
if (key === "telemetry_installed_at") {
|
|
133
|
-
throw new Error("config write failed");
|
|
134
|
-
}
|
|
135
132
|
writes.push({ key, value });
|
|
136
133
|
},
|
|
137
134
|
{ generateIfMissing: true },
|
|
138
135
|
);
|
|
139
|
-
//
|
|
140
|
-
//
|
|
136
|
+
// A pre-existing installation ID with no stored anchor must NOT mint
|
|
137
|
+
// now() as a stand-in install date — that back-fills a wrong date
|
|
138
|
+
// instead of an honestly-absent one. No write, no fabricated anchor.
|
|
141
139
|
expect(_getInstallationIdForTests()).toBe(existing);
|
|
142
140
|
expect(writes).toEqual([]);
|
|
143
141
|
expect(_getInstalledAtForTests()).toBeNull();
|
|
144
142
|
});
|
|
145
143
|
|
|
146
|
-
test("backfill: telemetry_installed_at write succeeds → mints and persists exactly once", async () => {
|
|
147
|
-
const existing = "install_backfillsuccess";
|
|
148
|
-
const writes: Array<{ key: string; value: string }> = [];
|
|
149
|
-
await initTelemetry(
|
|
150
|
-
"api-server",
|
|
151
|
-
async (key) => (key === "telemetry_installation_id" ? existing : undefined),
|
|
152
|
-
async (key, value) => {
|
|
153
|
-
writes.push({ key, value });
|
|
154
|
-
},
|
|
155
|
-
{ generateIfMissing: true },
|
|
156
|
-
);
|
|
157
|
-
expect(_getInstallationIdForTests()).toBe(existing);
|
|
158
|
-
const installedAt = _getInstalledAtForTests();
|
|
159
|
-
expect(installedAt).not.toBeNull();
|
|
160
|
-
expect(writes).toEqual([{ key: "telemetry_installed_at", value: installedAt as string }]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
144
|
test("already-exists: both installation_id and installed_at present → no writes, reuses existing anchor", async () => {
|
|
164
145
|
const existing = "install_alreadyexists";
|
|
165
146
|
const existingInstalledAt = "2026-01-01T00:00:00.000Z";
|
|
@@ -847,6 +828,22 @@ describe("initTelemetry", () => {
|
|
|
847
828
|
expect(typeof metadata.install_created_at).toBe("string");
|
|
848
829
|
});
|
|
849
830
|
|
|
831
|
+
test("pre-existing installationId with no stored anchor → install_created_at is absent from the payload, not back-filled with now()", async () => {
|
|
832
|
+
await initTelemetry(
|
|
833
|
+
"api-server",
|
|
834
|
+
async (key) =>
|
|
835
|
+
key === "telemetry_installation_id" ? "install_existingnoanchor" : undefined,
|
|
836
|
+
async () => {},
|
|
837
|
+
{ generateIfMissing: true },
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
track({ event: "server.started", properties: {} });
|
|
841
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
842
|
+
|
|
843
|
+
const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
|
|
844
|
+
expect(metadata.install_created_at).toBeUndefined();
|
|
845
|
+
});
|
|
846
|
+
|
|
850
847
|
test("wizard install: INSTALL_METHOD + INSTALL_PRESET flow through to properties/metadata", async () => {
|
|
851
848
|
process.env.INSTALL_METHOD = "onboard_noninteractive";
|
|
852
849
|
process.env.INSTALL_PRESET = "solo";
|
|
@@ -11,12 +11,17 @@ import {
|
|
|
11
11
|
closeDb,
|
|
12
12
|
createWorkflowRun,
|
|
13
13
|
createWorkflowRunStep,
|
|
14
|
+
getDb,
|
|
14
15
|
getWorkflowVersions,
|
|
15
16
|
initDb,
|
|
16
17
|
updateWorkflowRun,
|
|
17
18
|
} from "../be/db";
|
|
18
19
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
19
20
|
import { handleWorkflows } from "../http/workflows";
|
|
21
|
+
import {
|
|
22
|
+
listWorkflowRunsHandler,
|
|
23
|
+
listWorkflowRunsInputSchema,
|
|
24
|
+
} from "../tools/workflows/list-workflow-runs";
|
|
20
25
|
import type {
|
|
21
26
|
Workflow,
|
|
22
27
|
WorkflowDefinition,
|
|
@@ -549,6 +554,117 @@ describe("Workflow HTTP API v2", () => {
|
|
|
549
554
|
expect(run.status).toBe("waiting");
|
|
550
555
|
}
|
|
551
556
|
});
|
|
557
|
+
|
|
558
|
+
test("supports deterministic limit/offset pages while preserving the omitted legacy shape", async () => {
|
|
559
|
+
const workflow = await createTestWorkflow();
|
|
560
|
+
const runIds = Array.from({ length: 5 }, () => crypto.randomUUID());
|
|
561
|
+
const statuses = ["running", "failed", "running", "failed", "running"] as const;
|
|
562
|
+
|
|
563
|
+
for (const [index, id] of runIds.entries()) {
|
|
564
|
+
createWorkflowRun({ id, workflowId: workflow.id });
|
|
565
|
+
getDb().run("UPDATE workflow_runs SET status = ?, startedAt = ? WHERE id = ?", [
|
|
566
|
+
statuses[index]!,
|
|
567
|
+
new Date(Date.UTC(2026, 0, index + 1)).toISOString(),
|
|
568
|
+
id,
|
|
569
|
+
]);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const legacyRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers });
|
|
573
|
+
expect(legacyRes.status).toBe(200);
|
|
574
|
+
const legacyBody = (await legacyRes.json()) as WorkflowRun[];
|
|
575
|
+
expect(Array.isArray(legacyBody)).toBe(true);
|
|
576
|
+
expect(legacyBody.map((run) => run.id)).toEqual([...runIds].reverse());
|
|
577
|
+
|
|
578
|
+
const pageRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs?limit=2&offset=1`, {
|
|
579
|
+
headers,
|
|
580
|
+
});
|
|
581
|
+
expect(pageRes.status).toBe(200);
|
|
582
|
+
const pageBody = (await pageRes.json()) as {
|
|
583
|
+
runs: WorkflowRun[];
|
|
584
|
+
page: {
|
|
585
|
+
limit: number;
|
|
586
|
+
offset: number;
|
|
587
|
+
total: number;
|
|
588
|
+
hasMore: boolean;
|
|
589
|
+
nextOffset?: number;
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
expect(pageBody.runs.map((run) => run.id)).toEqual([runIds[3], runIds[2]]);
|
|
593
|
+
expect(pageBody.page).toEqual({
|
|
594
|
+
limit: 2,
|
|
595
|
+
offset: 1,
|
|
596
|
+
total: 5,
|
|
597
|
+
hasMore: true,
|
|
598
|
+
nextOffset: 3,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const filteredRes = await fetch(
|
|
602
|
+
`${baseUrl}/api/workflows/${workflow.id}/runs?status=failed&limit=1`,
|
|
603
|
+
{ headers },
|
|
604
|
+
);
|
|
605
|
+
expect(filteredRes.status).toBe(200);
|
|
606
|
+
const filteredBody = (await filteredRes.json()) as {
|
|
607
|
+
runs: WorkflowRun[];
|
|
608
|
+
page: { total: number };
|
|
609
|
+
};
|
|
610
|
+
expect(filteredBody.runs.map((run) => run.id)).toEqual([runIds[3]]);
|
|
611
|
+
expect(filteredBody.page.total).toBe(2);
|
|
612
|
+
|
|
613
|
+
const invalidRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs?limit=101`, {
|
|
614
|
+
headers,
|
|
615
|
+
});
|
|
616
|
+
expect(invalidRes.status).toBe(400);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("MCP run listing defaults to bounded slim rows and preserves an explicit full-row opt-in", async () => {
|
|
620
|
+
const workflow = await createTestWorkflow();
|
|
621
|
+
const largeValue = "x".repeat(100_000);
|
|
622
|
+
const run = createWorkflowRun({
|
|
623
|
+
id: crypto.randomUUID(),
|
|
624
|
+
workflowId: workflow.id,
|
|
625
|
+
triggerData: { largeValue },
|
|
626
|
+
});
|
|
627
|
+
updateWorkflowRun(run.id, { context: { nodeOutput: largeValue } });
|
|
628
|
+
const parsed = listWorkflowRunsInputSchema.parse({ workflowId: workflow.id });
|
|
629
|
+
expect(parsed.limit).toBe(20);
|
|
630
|
+
expect(parsed.offset).toBe(0);
|
|
631
|
+
expect(parsed.includeContext).toBe(false);
|
|
632
|
+
expect(() =>
|
|
633
|
+
listWorkflowRunsInputSchema.parse({ workflowId: workflow.id, limit: 101 }),
|
|
634
|
+
).toThrow();
|
|
635
|
+
|
|
636
|
+
const result = listWorkflowRunsHandler(parsed);
|
|
637
|
+
expect(result.ok).toBe(true);
|
|
638
|
+
const data = result.data as {
|
|
639
|
+
runs: Array<{
|
|
640
|
+
id: string;
|
|
641
|
+
context?: unknown;
|
|
642
|
+
triggerData?: unknown;
|
|
643
|
+
triggerDataSummary?: string;
|
|
644
|
+
}>;
|
|
645
|
+
page: { limit: number; offset: number; total: number; hasMore: boolean };
|
|
646
|
+
};
|
|
647
|
+
expect(data.runs.map((row) => row.id)).toEqual([run.id]);
|
|
648
|
+
expect(data.runs[0]!.context).toBeUndefined();
|
|
649
|
+
expect(data.runs[0]!.triggerData).toBeUndefined();
|
|
650
|
+
expect(data.runs[0]!.triggerDataSummary).toEndWith("…");
|
|
651
|
+
expect(data.runs[0]!.triggerDataSummary!.length).toBe(401);
|
|
652
|
+
expect(data.page).toEqual({
|
|
653
|
+
limit: 20,
|
|
654
|
+
offset: 0,
|
|
655
|
+
total: 1,
|
|
656
|
+
hasMore: false,
|
|
657
|
+
});
|
|
658
|
+
expect(JSON.stringify(result).length).toBeLessThan(20_000);
|
|
659
|
+
|
|
660
|
+
const fullResult = listWorkflowRunsHandler({
|
|
661
|
+
...parsed,
|
|
662
|
+
includeContext: true,
|
|
663
|
+
});
|
|
664
|
+
const fullData = fullResult.data as { runs: WorkflowRun[] };
|
|
665
|
+
expect(fullData.runs[0]!.context).toEqual({ nodeOutput: largeValue });
|
|
666
|
+
expect(fullData.runs[0]!.triggerData).toEqual({ largeValue });
|
|
667
|
+
});
|
|
552
668
|
});
|
|
553
669
|
|
|
554
670
|
// ─── GET RUN DETAIL ────────────────────────────────────────
|
package/src/tools/get-tasks.ts
CHANGED
|
@@ -79,6 +79,45 @@ export const getTasksOutputSchema = swarmToolOutputSchema({
|
|
|
79
79
|
|
|
80
80
|
type GetTasksArgs = z.infer<typeof getTasksInputSchema>;
|
|
81
81
|
|
|
82
|
+
const TASK_DETAILS_CELL_CAP = 180;
|
|
83
|
+
|
|
84
|
+
function escapeMarkdownCell(value: unknown): string {
|
|
85
|
+
const text = String(value ?? "—");
|
|
86
|
+
const compact =
|
|
87
|
+
text.length > TASK_DETAILS_CELL_CAP ? `${text.slice(0, TASK_DETAILS_CELL_CAP - 1)}…` : text;
|
|
88
|
+
return compact
|
|
89
|
+
.replaceAll("\\", "\\\\")
|
|
90
|
+
.replaceAll("|", "\\|")
|
|
91
|
+
.replace(/\r\n|\r|\n/g, "<br>");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderTaskSummaries(
|
|
95
|
+
tasks: Array<{
|
|
96
|
+
id?: string;
|
|
97
|
+
agentId?: string | null;
|
|
98
|
+
task?: string;
|
|
99
|
+
taskPreview?: string;
|
|
100
|
+
status?: string;
|
|
101
|
+
priority?: number;
|
|
102
|
+
}>,
|
|
103
|
+
): string {
|
|
104
|
+
if (tasks.length === 0) return "No tasks matched the current filters.";
|
|
105
|
+
|
|
106
|
+
const header = "| ID | Status | Priority | Agent | Task |";
|
|
107
|
+
const separator = "| --- | --- | ---: | --- | --- |";
|
|
108
|
+
const rows = tasks.map((task) => {
|
|
109
|
+
const cells = [
|
|
110
|
+
task.id,
|
|
111
|
+
task.status,
|
|
112
|
+
task.priority,
|
|
113
|
+
task.agentId,
|
|
114
|
+
task.taskPreview ?? task.task,
|
|
115
|
+
].map(escapeMarkdownCell);
|
|
116
|
+
return `| ${cells.join(" | ")} |`;
|
|
117
|
+
});
|
|
118
|
+
return [header, separator, ...rows].join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
export async function getTasksHandler(
|
|
83
122
|
ctx: ToolCtx,
|
|
84
123
|
{
|
|
@@ -163,7 +202,7 @@ export async function getTasksHandler(
|
|
|
163
202
|
};
|
|
164
203
|
|
|
165
204
|
return toolOk(`Found ${taskSummaries.length} task(s)${filterMsg}.`, {
|
|
166
|
-
details:
|
|
205
|
+
details: renderTaskSummaries(taskSummaries),
|
|
167
206
|
data,
|
|
168
207
|
});
|
|
169
208
|
}
|
package/src/tools/script-run.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./script-common";
|
|
11
11
|
|
|
12
12
|
export const SCRIPT_RUN_DESCRIPTION =
|
|
13
|
-
"Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.";
|
|
13
|
+
"Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Inline source executes without the `script-upsert` compile-time typecheck. Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.";
|
|
14
14
|
|
|
15
15
|
function renderRunOutput(data: unknown): string | undefined {
|
|
16
16
|
if (typeof data !== "object" || data === null) return undefined;
|
|
@@ -58,7 +58,7 @@ export const registerScriptRunTool = (server: McpServer) => {
|
|
|
58
58
|
.min(1)
|
|
59
59
|
.optional()
|
|
60
60
|
.describe(
|
|
61
|
-
|
|
61
|
+
'Inline TypeScript source to run without a compile-time typecheck. Must `export default async function (args, ctx)` — args FIRST, ctx second. Import `ScriptContext` from "swarm-sdk" for promotion-safe typing.',
|
|
62
62
|
),
|
|
63
63
|
args: z.unknown().optional().describe("JSON-serializable script arguments."),
|
|
64
64
|
intent: z.string().default("").describe("Why this script is being run."),
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./script-common";
|
|
11
11
|
|
|
12
12
|
export const SCRIPT_UPSERT_DESCRIPTION =
|
|
13
|
-
|
|
13
|
+
'Typecheck and persist a TypeScript script to the swarm catalog under your agent scope (or global if you\'re a lead). Import `ScriptContext` from "swarm-sdk" for a real context type. Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.';
|
|
14
14
|
|
|
15
15
|
export const registerScriptUpsertTool = (server: McpServer) => {
|
|
16
16
|
createToolRegistrar(server)(
|
|
@@ -25,7 +25,7 @@ export const registerScriptUpsertTool = (server: McpServer) => {
|
|
|
25
25
|
.string()
|
|
26
26
|
.min(1)
|
|
27
27
|
.describe(
|
|
28
|
-
|
|
28
|
+
'TypeScript source, typechecked before saving. Must `export default async function (args, ctx)` — args FIRST, ctx second. Import `ScriptContext` from "swarm-sdk" to type `ctx`.',
|
|
29
29
|
),
|
|
30
30
|
description: z.string().default("").describe("Human-readable script description."),
|
|
31
31
|
intent: z.string().default("").describe("Why this script exists."),
|
package/src/tools/utils.ts
CHANGED
|
@@ -189,13 +189,14 @@ export const NUDGES: Record<string, (result: SwarmToolResult) => string | undefi
|
|
|
189
189
|
},
|
|
190
190
|
};
|
|
191
191
|
|
|
192
|
-
// Cap for
|
|
193
|
-
//
|
|
194
|
-
|
|
192
|
+
// Cap for any rendered details string — Codex's ~10KB middle-out truncation is
|
|
193
|
+
// the tightest harness budget. Structured data remains intact; only the
|
|
194
|
+
// human/model-facing rendering is bounded.
|
|
195
|
+
const DETAILS_CAP = 8_000;
|
|
195
196
|
|
|
196
197
|
function truncateForText(text: string): string {
|
|
197
|
-
if (text.length <=
|
|
198
|
-
return `${text.slice(0,
|
|
198
|
+
if (text.length <= DETAILS_CAP) return text;
|
|
199
|
+
return `${text.slice(0, DETAILS_CAP)}\n… [truncated ${text.length - DETAILS_CAP} chars]`;
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
type FinalizeContext = { toolName: string };
|
|
@@ -235,17 +236,22 @@ export function finalizeSwarmToolResult(toolName: string, result: SwarmToolResul
|
|
|
235
236
|
}
|
|
236
237
|
for (const middleware of FINALIZE_PIPELINE) r = middleware(r, { toolName });
|
|
237
238
|
|
|
239
|
+
// Normalize and cap explicit details only after the scrub middleware has
|
|
240
|
+
// removed secrets. The same bounded value feeds both wire channels.
|
|
241
|
+
// Whitespace-only details count as absent so data fallback remains visible.
|
|
242
|
+
const explicitDetails = r.details?.trim() ? truncateForText(r.details.trim()) : undefined;
|
|
243
|
+
|
|
238
244
|
// Text-channel completeness guarantee: when a tool sets data but no details,
|
|
239
245
|
// render the data as JSON into the text channel. Most harnesses only ever
|
|
240
246
|
// show the model content.text — without this fallback, a data-only payload
|
|
241
247
|
// would be invisible there. Not copied into structuredContent.details (the
|
|
242
248
|
// structured channel already carries data verbatim).
|
|
243
249
|
const dataFallback =
|
|
244
|
-
!
|
|
250
|
+
!explicitDetails && r.data && Object.keys(r.data).length > 0
|
|
245
251
|
? truncateForText(JSON.stringify(r.data, null, 2))
|
|
246
252
|
: undefined;
|
|
247
253
|
|
|
248
|
-
const text = [r.message,
|
|
254
|
+
const text = [r.message, explicitDetails ?? dataFallback, r.nudge]
|
|
249
255
|
.filter((part): part is string => Boolean(part?.trim()))
|
|
250
256
|
.join("\n\n");
|
|
251
257
|
const structuredContent: Record<string, unknown> = {
|
|
@@ -253,7 +259,7 @@ export function finalizeSwarmToolResult(toolName: string, result: SwarmToolResul
|
|
|
253
259
|
success: r.ok,
|
|
254
260
|
message: r.message,
|
|
255
261
|
};
|
|
256
|
-
if (
|
|
262
|
+
if (explicitDetails) structuredContent.details = explicitDetails;
|
|
257
263
|
if (r.nudge) structuredContent.nudge = r.nudge;
|
|
258
264
|
|
|
259
265
|
return {
|