@desplega.ai/agent-swarm 1.52.0 ā 1.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/package.json +3 -1
- package/src/be/db.ts +27 -0
- package/src/be/migrations/runner.ts +4 -4
- package/src/commands/runner.ts +166 -14
- package/src/http/agents.ts +29 -0
- package/src/http/approval-requests.ts +63 -0
- package/src/http/index.ts +22 -2
- package/src/http/poll.ts +15 -0
- package/src/http/tasks.ts +94 -0
- package/src/linear/outbound.ts +12 -12
- package/src/providers/claude-adapter.ts +19 -3
- package/src/scheduler/scheduler.ts +24 -1
- package/src/slack/blocks.ts +1 -1
- package/src/tests/approval-requests.test.ts +214 -1
- package/src/tests/skill-sync.test.ts +1 -1
- package/src/tests/slack-blocks.test.ts +3 -2
- package/src/tests/structured-output.test.ts +1 -0
- package/src/tests/tool-call-progress.test.ts +207 -0
- package/src/tests/tool-registrar-no-input.test.ts +114 -0
- package/src/tests/update-profile-auth.test.ts +1 -0
- package/src/tools/request-human-input.ts +14 -3
- package/src/tools/store-progress.ts +31 -0
- package/src/tools/templates.ts +28 -0
- package/src/tools/utils.ts +9 -7
- package/src/workflows/executors/human-in-the-loop.ts +115 -2
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { humanizeToolName, toolCallToProgress } from "../commands/runner";
|
|
3
|
+
|
|
4
|
+
describe("toolCallToProgress", () => {
|
|
5
|
+
// --- Core Claude Code tools ---
|
|
6
|
+
|
|
7
|
+
test("Read tool includes emoji and short path", () => {
|
|
8
|
+
const result = toolCallToProgress("Read", {
|
|
9
|
+
file_path: "/Users/taras/Documents/code/agent-swarm/src/commands/runner.ts",
|
|
10
|
+
});
|
|
11
|
+
expect(result).toBe("š Reading commands/runner.ts");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Edit tool includes emoji and short path", () => {
|
|
15
|
+
const result = toolCallToProgress("Edit", {
|
|
16
|
+
file_path: "/Users/taras/code/src/index.ts",
|
|
17
|
+
});
|
|
18
|
+
expect(result).toBe("āļø Editing src/index.ts");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("MultiEdit uses same format as Edit", () => {
|
|
22
|
+
const result = toolCallToProgress("MultiEdit", {
|
|
23
|
+
file_path: "/a/b/c/d.ts",
|
|
24
|
+
});
|
|
25
|
+
expect(result).toBe("āļø Editing c/d.ts");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("Write tool includes emoji and short path", () => {
|
|
29
|
+
const result = toolCallToProgress("Write", {
|
|
30
|
+
file_path: "/Users/taras/code/new-file.ts",
|
|
31
|
+
});
|
|
32
|
+
expect(result).toBe("š Writing code/new-file.ts");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("Bash tool uses description when available", () => {
|
|
36
|
+
const result = toolCallToProgress("Bash", {
|
|
37
|
+
description: "Running tests",
|
|
38
|
+
command: "bun test",
|
|
39
|
+
});
|
|
40
|
+
expect(result).toBe("ā” Running tests");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Bash tool falls back when no description", () => {
|
|
44
|
+
const result = toolCallToProgress("Bash", { command: "ls -la" });
|
|
45
|
+
expect(result).toBe("ā” Running shell command");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("Grep tool shows search pattern", () => {
|
|
49
|
+
const result = toolCallToProgress("Grep", { pattern: "TODO" });
|
|
50
|
+
expect(result).toBe('š Searching for "TODO"');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("Glob tool shows file pattern", () => {
|
|
54
|
+
const result = toolCallToProgress("Glob", { pattern: "**/*.test.ts" });
|
|
55
|
+
expect(result).toBe("š Finding files matching **/*.test.ts");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("Agent tool uses description when available", () => {
|
|
59
|
+
const result = toolCallToProgress("Agent", {
|
|
60
|
+
description: "Exploring codebase structure",
|
|
61
|
+
});
|
|
62
|
+
expect(result).toBe("š¤ Exploring codebase structure");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("Agent tool falls back when no description", () => {
|
|
66
|
+
const result = toolCallToProgress("Agent", {});
|
|
67
|
+
expect(result).toBe("š¤ Delegating sub-task");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Task tool uses description", () => {
|
|
71
|
+
const result = toolCallToProgress("Task", {
|
|
72
|
+
description: "Running lint checks",
|
|
73
|
+
});
|
|
74
|
+
expect(result).toBe("š¤ Running lint checks");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("Skill tool shows skill name", () => {
|
|
78
|
+
const result = toolCallToProgress("Skill", { skill: "commit" });
|
|
79
|
+
expect(result).toBe("āļø Running /commit");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Skip list ---
|
|
83
|
+
|
|
84
|
+
test("ToolSearch is skipped (returns null)", () => {
|
|
85
|
+
expect(toolCallToProgress("ToolSearch", {})).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("TodoRead is skipped", () => {
|
|
89
|
+
expect(toolCallToProgress("TodoRead", {})).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("TodoWrite is skipped", () => {
|
|
93
|
+
expect(toolCallToProgress("TodoWrite", {})).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// --- Unknown default tools ---
|
|
97
|
+
|
|
98
|
+
test("unknown tool gets fallback with emoji", () => {
|
|
99
|
+
const result = toolCallToProgress("SomeNewTool", {});
|
|
100
|
+
expect(result).toBe("š§ SomeNewTool");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// --- Agent-swarm MCP tools (with pretty labels) ---
|
|
104
|
+
|
|
105
|
+
test("agent-swarm:get-task-details has pretty label", () => {
|
|
106
|
+
const result = toolCallToProgress("mcp__agent-swarm__get-task-details", {});
|
|
107
|
+
expect(result).toBe("š Reviewing task details");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("agent-swarm:send-task has pretty label", () => {
|
|
111
|
+
const result = toolCallToProgress("mcp__agent-swarm__send-task", {});
|
|
112
|
+
expect(result).toBe("š¤ Delegating task");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("agent-swarm:post-message has pretty label", () => {
|
|
116
|
+
const result = toolCallToProgress("mcp__agent-swarm__post-message", {});
|
|
117
|
+
expect(result).toBe("š¬ Sending message");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("agent-swarm:store-progress is skipped (meta/noise)", () => {
|
|
121
|
+
expect(toolCallToProgress("mcp__agent-swarm__store-progress", {})).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("agent-swarm:poll-task has pretty label", () => {
|
|
125
|
+
const result = toolCallToProgress("mcp__agent-swarm__poll-task", {});
|
|
126
|
+
expect(result).toBe("š” Polling for tasks");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("agent-swarm:request-human-input has pretty label", () => {
|
|
130
|
+
const result = toolCallToProgress("mcp__agent-swarm__request-human-input", {});
|
|
131
|
+
expect(result).toBe("š Requesting human input");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("agent-swarm:memory-search has pretty label", () => {
|
|
135
|
+
const result = toolCallToProgress("mcp__agent-swarm__memory-search", {});
|
|
136
|
+
expect(result).toBe("š§ Searching memory");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("agent-swarm:tracker-status has pretty label", () => {
|
|
140
|
+
const result = toolCallToProgress("mcp__agent-swarm__tracker-status", {});
|
|
141
|
+
expect(result).toBe("š Checking tracker status");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("agent-swarm:trigger-workflow has pretty label", () => {
|
|
145
|
+
const result = toolCallToProgress("mcp__agent-swarm__trigger-workflow", {});
|
|
146
|
+
expect(result).toBe("āļø Triggering workflow");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("agent-swarm:slack-post has pretty label", () => {
|
|
150
|
+
const result = toolCallToProgress("mcp__agent-swarm__slack-post", {});
|
|
151
|
+
expect(result).toBe("š¬ Posting to Slack");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// --- Agent-swarm MCP tool NOT in lookup (humanized fallback) ---
|
|
155
|
+
|
|
156
|
+
test("unknown agent-swarm tool gets humanized fallback", () => {
|
|
157
|
+
const result = toolCallToProgress("mcp__agent-swarm__some-new-tool", {});
|
|
158
|
+
expect(result).toBe("š Some new tool");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// --- Other MCP servers ---
|
|
162
|
+
|
|
163
|
+
test("other MCP server tool gets server prefix + humanized name", () => {
|
|
164
|
+
const result = toolCallToProgress("mcp__linear__list-issues", {});
|
|
165
|
+
expect(result).toBe("š linear: List issues");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("other MCP server with simple tool name", () => {
|
|
169
|
+
const result = toolCallToProgress("mcp__github__search", {});
|
|
170
|
+
expect(result).toBe("š github: Search");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("MCP tool with double underscores in tool name", () => {
|
|
174
|
+
const result = toolCallToProgress("mcp__context7__query-docs", {});
|
|
175
|
+
expect(result).toBe("š context7: Query docs");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// --- Short path helper (tested implicitly) ---
|
|
179
|
+
|
|
180
|
+
test("Read with short path (<=2 segments) keeps full path", () => {
|
|
181
|
+
const result = toolCallToProgress("Read", { file_path: "file.ts" });
|
|
182
|
+
expect(result).toBe("š Reading file.ts");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("Read with missing file_path shows empty", () => {
|
|
186
|
+
const result = toolCallToProgress("Read", {});
|
|
187
|
+
expect(result).toBe("š Reading ");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("humanizeToolName", () => {
|
|
192
|
+
test("converts kebab-case to sentence case", () => {
|
|
193
|
+
expect(humanizeToolName("get-task-details")).toBe("Get task details");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("single word gets capitalized", () => {
|
|
197
|
+
expect(humanizeToolName("search")).toBe("Search");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("multi-word kebab-case", () => {
|
|
201
|
+
expect(humanizeToolName("list-all-workflow-runs")).toBe("List all workflow runs");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("empty string returns empty", () => {
|
|
205
|
+
expect(humanizeToolName("")).toBe("");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { createToolRegistrar } from "../tools/utils";
|
|
5
|
+
|
|
6
|
+
describe("createToolRegistrar with no inputSchema", () => {
|
|
7
|
+
test("handler receives requestInfo from meta when no inputSchema is defined", async () => {
|
|
8
|
+
const server = new McpServer({ name: "test-no-input", version: "1.0.0" });
|
|
9
|
+
|
|
10
|
+
let receivedRequestInfo: unknown = null;
|
|
11
|
+
let receivedMeta: unknown = null;
|
|
12
|
+
|
|
13
|
+
createToolRegistrar(server)(
|
|
14
|
+
"test-no-input-tool",
|
|
15
|
+
{
|
|
16
|
+
title: "Test Tool",
|
|
17
|
+
description: "Tool with no inputSchema",
|
|
18
|
+
outputSchema: z.object({ ok: z.boolean() }),
|
|
19
|
+
},
|
|
20
|
+
async (requestInfo, meta) => {
|
|
21
|
+
receivedRequestInfo = requestInfo;
|
|
22
|
+
receivedMeta = meta;
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text" as const, text: "ok" }],
|
|
25
|
+
structuredContent: { ok: true },
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Access the internal registered tool handler directly
|
|
31
|
+
// The MCP SDK stores tools in _registeredTools and calls handler(extra) for no-inputSchema
|
|
32
|
+
const registeredTools = (server as Record<string, unknown>)._registeredTools as Record<
|
|
33
|
+
string,
|
|
34
|
+
{ handler: (extra: unknown) => Promise<unknown> }
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
const tool = registeredTools["test-no-input-tool"];
|
|
38
|
+
expect(tool).toBeDefined();
|
|
39
|
+
|
|
40
|
+
// Simulate how the MCP SDK calls the handler for tools without inputSchema:
|
|
41
|
+
// It calls handler(extra) with a single argument (the Meta/extra object)
|
|
42
|
+
const fakeExtra = {
|
|
43
|
+
sessionId: "test-session-123",
|
|
44
|
+
requestInfo: {
|
|
45
|
+
headers: {
|
|
46
|
+
"x-agent-id": "agent-abc",
|
|
47
|
+
"x-source-task-id": "task-xyz",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// This is the critical test: the handler should NOT throw
|
|
53
|
+
// Before the fix, this would throw "undefined is not an object (evaluating 'req.requestInfo')"
|
|
54
|
+
const result = await tool.handler(fakeExtra);
|
|
55
|
+
|
|
56
|
+
expect(receivedRequestInfo).toEqual({
|
|
57
|
+
sessionId: "test-session-123",
|
|
58
|
+
agentId: "agent-abc",
|
|
59
|
+
sourceTaskId: "task-xyz",
|
|
60
|
+
});
|
|
61
|
+
expect(receivedMeta).toBe(fakeExtra);
|
|
62
|
+
expect(result).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("handler with inputSchema still receives args correctly", async () => {
|
|
66
|
+
const server = new McpServer({ name: "test-with-input", version: "1.0.0" });
|
|
67
|
+
|
|
68
|
+
let receivedArgs: unknown = null;
|
|
69
|
+
let receivedRequestInfo: unknown = null;
|
|
70
|
+
|
|
71
|
+
createToolRegistrar(server)(
|
|
72
|
+
"test-with-input-tool",
|
|
73
|
+
{
|
|
74
|
+
title: "Test Tool With Input",
|
|
75
|
+
description: "Tool with inputSchema",
|
|
76
|
+
inputSchema: z.object({ name: z.string() }),
|
|
77
|
+
outputSchema: z.object({ greeting: z.string() }),
|
|
78
|
+
},
|
|
79
|
+
async (args, requestInfo, _meta) => {
|
|
80
|
+
receivedArgs = args;
|
|
81
|
+
receivedRequestInfo = requestInfo;
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text" as const, text: "ok" }],
|
|
84
|
+
structuredContent: { greeting: `Hello ${args.name}` },
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const registeredTools = (server as Record<string, unknown>)._registeredTools as Record<
|
|
90
|
+
string,
|
|
91
|
+
{ handler: (args: unknown, extra: unknown) => Promise<unknown> }
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
const tool = registeredTools["test-with-input-tool"];
|
|
95
|
+
|
|
96
|
+
// MCP SDK calls handler(args, extra) when inputSchema is defined
|
|
97
|
+
const fakeExtra = {
|
|
98
|
+
sessionId: "test-session-456",
|
|
99
|
+
requestInfo: {
|
|
100
|
+
headers: { "x-agent-id": "agent-def" },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = await tool.handler({ name: "World" }, fakeExtra);
|
|
105
|
+
|
|
106
|
+
expect(receivedArgs).toEqual({ name: "World" });
|
|
107
|
+
expect(receivedRequestInfo).toEqual({
|
|
108
|
+
sessionId: "test-session-456",
|
|
109
|
+
agentId: "agent-def",
|
|
110
|
+
sourceTaskId: undefined,
|
|
111
|
+
});
|
|
112
|
+
expect(result).toBeDefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -28,6 +28,7 @@ async function callUpdateProfile(
|
|
|
28
28
|
callerAgentId: string | undefined,
|
|
29
29
|
args: Record<string, unknown>,
|
|
30
30
|
): Promise<{ structuredContent: StructuredContent }> {
|
|
31
|
+
// biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
|
|
31
32
|
const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
|
|
32
33
|
._registeredTools;
|
|
33
34
|
const handler = tools["update-profile"].handler;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import { createApprovalRequest } from "@/be/db";
|
|
3
|
+
import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
5
|
|
|
6
6
|
const QuestionSchema = z.object({
|
|
@@ -73,18 +73,29 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Resolve sourceTaskId: prefer header, fall back to agent's current in-progress task.
|
|
77
|
+
// The X-Source-Task-Id header may be missing when the per-session MCP config wasn't
|
|
78
|
+
// created (e.g. .mcp.json not found, session resumed, or lead agent on a non-task trigger).
|
|
79
|
+
let sourceTaskId = requestInfo.sourceTaskId;
|
|
80
|
+
if (!sourceTaskId && requestInfo.agentId) {
|
|
81
|
+
const currentTask = getAgentCurrentTask(requestInfo.agentId);
|
|
82
|
+
if (currentTask) {
|
|
83
|
+
sourceTaskId = currentTask.id;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
const id = crypto.randomUUID();
|
|
77
88
|
const request = createApprovalRequest({
|
|
78
89
|
id,
|
|
79
90
|
title,
|
|
80
91
|
questions,
|
|
81
92
|
approvers: { policy: "any" },
|
|
82
|
-
sourceTaskId:
|
|
93
|
+
sourceTaskId: sourceTaskId ?? undefined,
|
|
83
94
|
timeoutSeconds,
|
|
84
95
|
});
|
|
85
96
|
|
|
86
97
|
const appUrl = process.env.APP_URL || "http://localhost:5274";
|
|
87
|
-
const url = `${appUrl}/requests/${request.id}`;
|
|
98
|
+
const url = `${appUrl}/approval-requests/${request.id}`;
|
|
88
99
|
|
|
89
100
|
return {
|
|
90
101
|
content: [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensure } from "@desplega.ai/business-use";
|
|
1
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import * as z from "zod";
|
|
3
4
|
import {
|
|
@@ -161,6 +162,21 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
161
162
|
const result = completeTask(taskId, output);
|
|
162
163
|
if (result) {
|
|
163
164
|
updatedTask = result;
|
|
165
|
+
|
|
166
|
+
ensure({
|
|
167
|
+
id: "completed",
|
|
168
|
+
flow: "task",
|
|
169
|
+
runId: taskId,
|
|
170
|
+
depIds: ["started"],
|
|
171
|
+
data: {
|
|
172
|
+
taskId,
|
|
173
|
+
agentId: existingTask.agentId,
|
|
174
|
+
previousStatus: existingTask.status,
|
|
175
|
+
hasOutput: !!output,
|
|
176
|
+
},
|
|
177
|
+
validator: (data) => data.previousStatus === "in_progress",
|
|
178
|
+
});
|
|
179
|
+
|
|
164
180
|
if (existingTask.agentId) {
|
|
165
181
|
// Derive status from capacity instead of always setting idle
|
|
166
182
|
updateAgentStatusFromCapacity(existingTask.agentId);
|
|
@@ -170,6 +186,21 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
170
186
|
const result = failTask(taskId, failureReason ?? "Unknown failure");
|
|
171
187
|
if (result) {
|
|
172
188
|
updatedTask = result;
|
|
189
|
+
|
|
190
|
+
ensure({
|
|
191
|
+
id: "failed",
|
|
192
|
+
flow: "task",
|
|
193
|
+
runId: taskId,
|
|
194
|
+
depIds: ["started"],
|
|
195
|
+
data: {
|
|
196
|
+
taskId,
|
|
197
|
+
agentId: existingTask.agentId,
|
|
198
|
+
previousStatus: existingTask.status,
|
|
199
|
+
failureReason: failureReason ?? "Unknown failure",
|
|
200
|
+
},
|
|
201
|
+
validator: (data) => data.previousStatus === "in_progress",
|
|
202
|
+
});
|
|
203
|
+
|
|
173
204
|
if (existingTask.agentId) {
|
|
174
205
|
// Derive status from capacity instead of always setting idle
|
|
175
206
|
updateAgentStatusFromCapacity(existingTask.agentId);
|
package/src/tools/templates.ts
CHANGED
|
@@ -32,6 +32,34 @@ Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
|
|
|
32
32
|
category: "task_lifecycle",
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// HITL follow-up (created when a standalone approval request is resolved)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
registerTemplate({
|
|
40
|
+
eventType: "hitl.follow_up",
|
|
41
|
+
header: "",
|
|
42
|
+
defaultBody: `Human responded to your approval request ({{request_id}}).
|
|
43
|
+
|
|
44
|
+
Title: {{title}}
|
|
45
|
+
Status: {{status}}
|
|
46
|
+
|
|
47
|
+
Questions and responses:
|
|
48
|
+
{{responses}}
|
|
49
|
+
|
|
50
|
+
Continue your work based on the human's input.`,
|
|
51
|
+
variables: [
|
|
52
|
+
{ name: "request_id", description: "The approval request ID" },
|
|
53
|
+
{ name: "title", description: "Title of the approval request" },
|
|
54
|
+
{ name: "status", description: "Resolution status: approved or rejected" },
|
|
55
|
+
{
|
|
56
|
+
name: "responses",
|
|
57
|
+
description: "Formatted questions and human responses",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
category: "task_lifecycle",
|
|
61
|
+
});
|
|
62
|
+
|
|
35
63
|
registerTemplate({
|
|
36
64
|
eventType: "task.worker.failed",
|
|
37
65
|
header: "",
|
package/src/tools/utils.ts
CHANGED
|
@@ -101,17 +101,19 @@ export const createToolRegistrar = (server: McpServer) => {
|
|
|
101
101
|
config: ToolConfig<InputArgs, OutputArgs>,
|
|
102
102
|
cb: ToolCallbackWithInfo<InputArgs>,
|
|
103
103
|
) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
// When inputSchema is undefined, the MCP SDK calls handler(extra) with a single arg.
|
|
105
|
+
// When inputSchema is defined, it calls handler(args, extra) with two args.
|
|
106
|
+
if (config.inputSchema === undefined) {
|
|
107
|
+
return server.registerTool(name, config, ((meta: Meta) => {
|
|
108
|
+
const requestInfo = getRequestInfo(meta);
|
|
109
109
|
return (
|
|
110
110
|
cb as (requestInfo: RequestInfo, meta: Meta) => CallToolResult | Promise<CallToolResult>
|
|
111
111
|
)(requestInfo, meta);
|
|
112
|
-
}
|
|
112
|
+
}) as Parameters<typeof server.registerTool>[2]);
|
|
113
|
+
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
return server.registerTool(name, config, ((args: InferInput<InputArgs>, meta: Meta) => {
|
|
116
|
+
const requestInfo = getRequestInfo(meta);
|
|
115
117
|
return (
|
|
116
118
|
cb as (
|
|
117
119
|
args: InferInput<InputArgs>,
|
|
@@ -146,10 +146,17 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
|
|
|
146
146
|
workflowRunId: meta.runId,
|
|
147
147
|
workflowRunStepId: meta.stepId,
|
|
148
148
|
timeoutSeconds: config.timeout?.seconds,
|
|
149
|
-
notificationChannels: config.notifications,
|
|
149
|
+
notificationChannels: config.notifications,
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
-
// 3.
|
|
152
|
+
// 3. Dispatch notifications (fire-and-forget ā failures must not block the workflow)
|
|
153
|
+
if (config.notifications?.length) {
|
|
154
|
+
this.dispatchNotifications(requestId, config, db).catch((err) => {
|
|
155
|
+
console.error("[HITL] Unexpected error dispatching notifications:", err);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Return async result ā engine will pause the workflow
|
|
153
160
|
return {
|
|
154
161
|
status: "success",
|
|
155
162
|
async: true,
|
|
@@ -157,4 +164,110 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
|
|
|
157
164
|
correlationId: requestId,
|
|
158
165
|
} as unknown as ExecutorResult<HITLOutput>;
|
|
159
166
|
}
|
|
167
|
+
|
|
168
|
+
/** Dispatch notifications for each configured channel. Updates DB with messageTs on success. */
|
|
169
|
+
private async dispatchNotifications(
|
|
170
|
+
requestId: string,
|
|
171
|
+
config: z.infer<typeof HITLConfigSchema>,
|
|
172
|
+
db: typeof import("../../be/db"),
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
if (!config.notifications?.length) return;
|
|
175
|
+
|
|
176
|
+
const approvalUrl = `https://app.agent-swarm.dev/approval-requests/${requestId}`;
|
|
177
|
+
const updatedChannels = [...config.notifications] as Array<
|
|
178
|
+
z.infer<typeof NotificationConfigSchema> & { messageTs?: string }
|
|
179
|
+
>;
|
|
180
|
+
let updated = false;
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < config.notifications.length; i++) {
|
|
183
|
+
const notification = config.notifications[i]!;
|
|
184
|
+
|
|
185
|
+
if (notification.channel === "email") {
|
|
186
|
+
console.warn(
|
|
187
|
+
`[HITL] Email notifications not yet supported (target: ${notification.target})`,
|
|
188
|
+
);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (notification.channel === "slack") {
|
|
193
|
+
try {
|
|
194
|
+
const { getSlackApp } = await import("../../slack/app");
|
|
195
|
+
const slackApp = getSlackApp();
|
|
196
|
+
if (!slackApp) {
|
|
197
|
+
console.warn("[HITL] Slack not initialized ā cannot send notification");
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const questionsSummary = config.questions.map((q) => `⢠${q.label}`).join("\n");
|
|
202
|
+
|
|
203
|
+
const timeoutText = config.timeout
|
|
204
|
+
? `\nā± _Timeout: ${formatTimeout(config.timeout.seconds)} ā auto-rejects if not responded_`
|
|
205
|
+
: "";
|
|
206
|
+
|
|
207
|
+
const blocks = [
|
|
208
|
+
{
|
|
209
|
+
type: "section",
|
|
210
|
+
text: {
|
|
211
|
+
type: "mrkdwn",
|
|
212
|
+
text: `š *Approval Required: ${config.title}*`,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: "section",
|
|
217
|
+
text: {
|
|
218
|
+
type: "mrkdwn",
|
|
219
|
+
text: `*Questions:*\n${questionsSummary}${timeoutText}`,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: "actions",
|
|
224
|
+
elements: [
|
|
225
|
+
{
|
|
226
|
+
type: "button",
|
|
227
|
+
text: { type: "plain_text", text: "Review & Respond" },
|
|
228
|
+
url: approvalUrl,
|
|
229
|
+
style: "primary",
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const result = await slackApp.client.chat.postMessage({
|
|
236
|
+
channel: notification.target,
|
|
237
|
+
text: `Approval Required: ${config.title} ā ${approvalUrl}`,
|
|
238
|
+
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
239
|
+
blocks: blocks as any,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (result.ts) {
|
|
243
|
+
updatedChannels[i] = { ...notification, messageTs: result.ts };
|
|
244
|
+
updated = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
console.log(
|
|
248
|
+
`[HITL] Slack notification sent to ${notification.target} for request ${requestId}`,
|
|
249
|
+
);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error(`[HITL] Failed to send Slack notification to ${notification.target}:`, err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Persist messageTs values back to DB
|
|
257
|
+
if (updated) {
|
|
258
|
+
try {
|
|
259
|
+
db.updateApprovalRequestNotifications(requestId, updatedChannels);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.error("[HITL] Failed to update notification channels in DB:", err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatTimeout(seconds: number): string {
|
|
268
|
+
if (seconds < 60) return `${seconds}s`;
|
|
269
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
270
|
+
const hours = Math.floor(seconds / 3600);
|
|
271
|
+
const mins = Math.round((seconds % 3600) / 60);
|
|
272
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
160
273
|
}
|