@2kw/ai-mcp-server 6.2.2 → 6.3.0-dev.105
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 +3 -1
- package/dist/client.d.ts +9 -2
- package/dist/client.js +15 -1
- package/dist/index.js +5 -53
- package/dist/lib/agent-decide.d.ts +54 -0
- package/dist/lib/agent-decide.js +124 -0
- package/dist/lib/agent-run.d.ts +124 -0
- package/dist/lib/agent-run.js +260 -0
- package/dist/lib/overlay.d.ts +10 -0
- package/dist/lib/overlay.js +20 -0
- package/dist/tools/agents.js +92 -4
- package/dist/tools/ai-gateway.js +25 -11
- package/dist/tools/conversations.js +26 -0
- package/dist/tools/datasets.js +10 -15
- package/dist/tools/experiments.js +24 -28
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +190 -0
- package/dist/tools/knowledge.js +7 -23
- package/dist/tools/memory.d.ts +9 -0
- package/dist/tools/memory.js +43 -0
- package/dist/tools/prompts.js +10 -9
- package/dist/tools/schemas.js +14 -7
- package/dist/tools/tracing.js +10 -2
- package/package.json +3 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run envelope of `bb agents run --json` (spec 2026-09-14-cli-agents-design.md §4.3), copied
|
|
3
|
+
* from `cli/src/lib/agent-run.ts` because cli/ and mcp/ share no package (#667). Keep the two in step;
|
|
4
|
+
* only the text of `next` differs, because an MCP caller decides through a tool, not a shell command.
|
|
5
|
+
*/
|
|
6
|
+
/** Fallback chat.2kw.ai origin when the API host is not one we recognise. */
|
|
7
|
+
export const DEFAULT_CHAT_URL = "https://chat.2kw.ai";
|
|
8
|
+
/** Known API-host → chat web host mappings, as the CLI's `CHAT_URL_BY_API_HOST` (#807 R11). */
|
|
9
|
+
const CHAT_URL_BY_API_HOST = {
|
|
10
|
+
"api.2kw.ai": "https://chat.2kw.ai",
|
|
11
|
+
"api-dev.2kw.ai": "https://chat-dev.2kw.ai",
|
|
12
|
+
"backbone.manfred-kunze.dev": "https://chat.2kw.ai",
|
|
13
|
+
"localhost:8080": "http://localhost:3000",
|
|
14
|
+
"127.0.0.1:8080": "http://localhost:3000",
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The chat web host that belongs to the server's API base URL (`AI_2KW_BASE_URL`), where a member
|
|
18
|
+
* connects a connector. `AI_2KW_CHAT_URL` overrides the mapping; a trailing slash is dropped.
|
|
19
|
+
*/
|
|
20
|
+
export function chatUrlFor(baseUrl, env = process.env) {
|
|
21
|
+
const override = env.AI_2KW_CHAT_URL?.trim();
|
|
22
|
+
if (override)
|
|
23
|
+
return override.replace(/\/+$/, "");
|
|
24
|
+
try {
|
|
25
|
+
const host = new URL(baseUrl ?? "").host;
|
|
26
|
+
// hasOwn: a host named after an Object.prototype key must not resolve.
|
|
27
|
+
return Object.hasOwn(CHAT_URL_BY_API_HOST, host) ? CHAT_URL_BY_API_HOST[host] : DEFAULT_CHAT_URL;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return DEFAULT_CHAT_URL;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const CONVERSATION_MODES = ["plan", "ask", "auto"];
|
|
34
|
+
/**
|
|
35
|
+
* Who may change the mode (#656 D8), stated in both tool descriptions: the mode outlives the request
|
|
36
|
+
* that sets it, so a model that switched on its own would lift the user's choice for every later turn.
|
|
37
|
+
*/
|
|
38
|
+
export const MODE_RULE = "Set `mode` only when the user explicitly asks for that mode. Never set it to retry a refused approval " +
|
|
39
|
+
"or to leave plan mode on your own: ask the user first. plan = read-only; ask = no automatic approver, " +
|
|
40
|
+
"calls that need approval wait for a person; auto = the operator's policy as written. Leave it out to " +
|
|
41
|
+
"keep the conversation's mode.";
|
|
42
|
+
function isConversationMode(value) {
|
|
43
|
+
return typeof value === "string" && CONVERSATION_MODES.includes(value);
|
|
44
|
+
}
|
|
45
|
+
/** The `backbone:mode` input item (S1 D7); always appended last. */
|
|
46
|
+
export function modeItem(mode) {
|
|
47
|
+
return { type: "backbone:mode", mode };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The mode the response ran under (`conversation_mode`, #656). An absent key (a server before #656)
|
|
51
|
+
* and any value other than the three read as null, which also means "none set".
|
|
52
|
+
*/
|
|
53
|
+
export function responseMode(result) {
|
|
54
|
+
const raw = result?.conversation_mode;
|
|
55
|
+
return isConversationMode(raw) ? raw : null;
|
|
56
|
+
}
|
|
57
|
+
/** The assistant's text: every `output_text` part of every `message` item, in order. */
|
|
58
|
+
export function extractResponseText(result) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (const item of result?.output ?? []) {
|
|
61
|
+
if (item.type !== "message")
|
|
62
|
+
continue;
|
|
63
|
+
for (const part of item.content ?? []) {
|
|
64
|
+
if (part.type === "output_text" && part.text)
|
|
65
|
+
parts.push(part.text);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parts.join("\n");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The response echoes `model` as `agent/{name}@{versionNumber}` (all digits), optionally
|
|
72
|
+
* followed by a `#<model>` override. Only a trailing all-digit `@N` is read as a version;
|
|
73
|
+
* any other `@` suffix stays part of the name.
|
|
74
|
+
*/
|
|
75
|
+
export function parseAgentModel(model) {
|
|
76
|
+
// Strip the `#<model>` override (from the last `#`, as the server splits it) before matching.
|
|
77
|
+
const hash = (model ?? "").lastIndexOf("#");
|
|
78
|
+
const ref = hash >= 0 ? (model ?? "").slice(0, hash) : model ?? "";
|
|
79
|
+
const withVersion = /^agent\/(.+)@(\d+)$/.exec(ref);
|
|
80
|
+
if (withVersion)
|
|
81
|
+
return { agent: withVersion[1], version: Number(withVersion[2]) };
|
|
82
|
+
const bare = /^agent\/(.+)$/.exec(ref);
|
|
83
|
+
if (bare)
|
|
84
|
+
return { agent: bare[1], version: null };
|
|
85
|
+
return { agent: null, version: null };
|
|
86
|
+
}
|
|
87
|
+
function parseArguments(raw) {
|
|
88
|
+
if (typeof raw !== "string")
|
|
89
|
+
return raw ?? {};
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return raw;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** An open connector consent request; a continuation's projection carries another status. */
|
|
98
|
+
function isOpenConnectRequest(item) {
|
|
99
|
+
return item.type === "backbone:connector_auth_request" && (item.status === undefined || item.status === "in_progress");
|
|
100
|
+
}
|
|
101
|
+
function pendingConnectionsOf(output) {
|
|
102
|
+
return output.filter(isOpenConnectRequest).map((i) => ({
|
|
103
|
+
serverLabel: String(i.server_label),
|
|
104
|
+
host: String(i.host),
|
|
105
|
+
reason: String(i.reason),
|
|
106
|
+
...(Array.isArray(i.destinations) && i.destinations.length > 0
|
|
107
|
+
? { destinations: i.destinations.map((d) => String(d)) }
|
|
108
|
+
: {}),
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The call ids a connect pause withholds from the caller: the call each request names, and every
|
|
113
|
+
* `mcp__<label>__connect` call for a pending connector (one request stands for all of them).
|
|
114
|
+
*/
|
|
115
|
+
function connectCallIds(output, connections) {
|
|
116
|
+
const connectNames = new Set(connections.map((c) => `mcp__${c.serverLabel}__connect`));
|
|
117
|
+
const ids = new Set();
|
|
118
|
+
for (const item of output) {
|
|
119
|
+
if (isOpenConnectRequest(item) || (item.type === "function_call" && connectNames.has(String(item.name)))) {
|
|
120
|
+
ids.add(String(item.call_id));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return ids;
|
|
124
|
+
}
|
|
125
|
+
function connectionPhrase(c) {
|
|
126
|
+
const target = `${c.serverLabel} (${c.host})`;
|
|
127
|
+
if (c.reason === "allow")
|
|
128
|
+
return `allow the agent to use ${target}`;
|
|
129
|
+
if (c.reason === "reconnect")
|
|
130
|
+
return `reconnect ${target}`;
|
|
131
|
+
return `connect ${target}`;
|
|
132
|
+
}
|
|
133
|
+
function connectionsPhrase(connections) {
|
|
134
|
+
const phrases = connections.map(connectionPhrase);
|
|
135
|
+
return phrases.length > 1 ? `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}` : phrases[0] ?? "";
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
|
|
139
|
+
* connector calls. A pause on anything else (a connector approval's `mcp_approval_request`) is named.
|
|
140
|
+
*/
|
|
141
|
+
const DECODED_ITEM_TYPES = new Set([
|
|
142
|
+
"message",
|
|
143
|
+
"reasoning",
|
|
144
|
+
"function_call",
|
|
145
|
+
"function_call_output",
|
|
146
|
+
"backbone:approval_request",
|
|
147
|
+
"backbone:connector_auth_request",
|
|
148
|
+
"mcp_call",
|
|
149
|
+
"mcp_list_tools",
|
|
150
|
+
]);
|
|
151
|
+
function undecodedPause(output) {
|
|
152
|
+
const types = [...new Set(output.map((i) => String(i.type)).filter((t) => !DECODED_ITEM_TYPES.has(t)))];
|
|
153
|
+
return types.length > 0
|
|
154
|
+
? `This server cannot show or answer ${types.join(", ")}.`
|
|
155
|
+
: "This server cannot tell what the run waits for.";
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* @param agentRef the reference the run was started with (`name[@label][#model]`); `next` names it so
|
|
159
|
+
* the decision continues on the same label and model (D11). Falls back to the echoed agent name.
|
|
160
|
+
* @param chatUrl the chat web host of the API ({@link chatUrlFor}); a connect pause's `next` names its
|
|
161
|
+
* Connectors page.
|
|
162
|
+
*/
|
|
163
|
+
export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
|
|
164
|
+
const output = result?.output ?? [];
|
|
165
|
+
const { agent, version } = parseAgentModel(result?.model);
|
|
166
|
+
// call_id → status of its tool output; a failed server-side tool run is marked `incomplete`.
|
|
167
|
+
const outputStatuses = new Map(output
|
|
168
|
+
.filter((i) => i.type === "function_call_output")
|
|
169
|
+
.map((i) => [
|
|
170
|
+
String(i.call_id),
|
|
171
|
+
i.status === "incomplete" ? "incomplete" : "completed",
|
|
172
|
+
]));
|
|
173
|
+
const pendingApprovals = output
|
|
174
|
+
// Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
|
|
175
|
+
.filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
|
|
176
|
+
.map((i) => ({
|
|
177
|
+
approvalId: String(i.id),
|
|
178
|
+
callId: String(i.call_id),
|
|
179
|
+
tool: String(i.tool),
|
|
180
|
+
arguments: parseArguments(i.arguments),
|
|
181
|
+
policyClass: String(i.policy_class),
|
|
182
|
+
reason: typeof i.reason === "string" && i.reason ? i.reason : null,
|
|
183
|
+
}));
|
|
184
|
+
const pendingConnections = pendingConnectionsOf(output);
|
|
185
|
+
const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, pendingConnections)]);
|
|
186
|
+
const toolCalls = [];
|
|
187
|
+
const pendingToolCalls = [];
|
|
188
|
+
for (const item of output) {
|
|
189
|
+
if (item.type !== "function_call")
|
|
190
|
+
continue;
|
|
191
|
+
const callId = String(item.call_id);
|
|
192
|
+
const outputStatus = outputStatuses.get(callId);
|
|
193
|
+
if (outputStatus) {
|
|
194
|
+
toolCalls.push({ tool: String(item.name), callId, status: outputStatus });
|
|
195
|
+
}
|
|
196
|
+
else if (!withheldCallIds.has(callId)) {
|
|
197
|
+
pendingToolCalls.push({ tool: String(item.name), callId, arguments: parseArguments(item.arguments) });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let status;
|
|
201
|
+
switch (result?.status) {
|
|
202
|
+
case "completed":
|
|
203
|
+
status = "completed";
|
|
204
|
+
break;
|
|
205
|
+
case "incomplete":
|
|
206
|
+
status = "incomplete";
|
|
207
|
+
break;
|
|
208
|
+
case "requires_action":
|
|
209
|
+
// A connect pause is requires_tool_output too (#807 R12): no tool here can answer it.
|
|
210
|
+
status =
|
|
211
|
+
pendingToolCalls.length > 0 || pendingConnections.length > 0 || pendingApprovals.length === 0
|
|
212
|
+
? "requires_tool_output"
|
|
213
|
+
: "requires_approval";
|
|
214
|
+
break;
|
|
215
|
+
default:
|
|
216
|
+
throw new Error(`Unexpected response status: ${result?.status}`);
|
|
217
|
+
}
|
|
218
|
+
const responseId = result?.id ?? null;
|
|
219
|
+
const ref = agentRef ?? agent;
|
|
220
|
+
let next = null;
|
|
221
|
+
if (status === "requires_approval" && ref && responseId) {
|
|
222
|
+
// Never an "approve all" hint: the assistant decides only what the user decided (D12).
|
|
223
|
+
next = `Show the pending approvals to the user; after they decide each one, call 2kw_decide_agent_approvals with agent ${JSON.stringify(ref)} and responseId ${JSON.stringify(responseId)}.`;
|
|
224
|
+
}
|
|
225
|
+
else if (status === "requires_tool_output" && pendingConnections.length > 0 && pendingToolCalls.length === 0 && responseId) {
|
|
226
|
+
// No tool here takes previous_response_id, and a connect pause cannot be continued by
|
|
227
|
+
// conversation (the gateway answers 400), so the honest ways on are a fresh request or a
|
|
228
|
+
// client that continues by response id.
|
|
229
|
+
next =
|
|
230
|
+
`Tell the user to ${connectionsPhrase(pendingConnections)} in ${chatUrl}/connectors. ` +
|
|
231
|
+
`This server cannot continue response ${JSON.stringify(responseId)}: once they have connected, send the request again ` +
|
|
232
|
+
"with 2kw_create_response without `conversation` (a connect pause cannot be continued by conversation), " +
|
|
233
|
+
"or continue the response by its id from n8n (Previous Response ID) or any client that sends previous_response_id.";
|
|
234
|
+
}
|
|
235
|
+
else if (status === "requires_tool_output" && pendingConnections.length === 0 && pendingToolCalls.length === 0 && responseId) {
|
|
236
|
+
// Paused on nothing the envelope decodes: say so, rather than hand back a pause with no way on.
|
|
237
|
+
next =
|
|
238
|
+
`${undecodedPause(output)} Response ${JSON.stringify(responseId)} stays paused: tell the user to answer it ` +
|
|
239
|
+
`in ${chatUrl}/, or continue it from a client that sends previous_response_id.`;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
status,
|
|
243
|
+
mode: responseMode(result),
|
|
244
|
+
agent,
|
|
245
|
+
version,
|
|
246
|
+
responseId,
|
|
247
|
+
conversationId: result?.conversation?.id ?? null,
|
|
248
|
+
text: extractResponseText(result),
|
|
249
|
+
toolCalls,
|
|
250
|
+
pendingApprovals,
|
|
251
|
+
pendingToolCalls,
|
|
252
|
+
pendingConnections,
|
|
253
|
+
incompleteReason: result?.incomplete_details?.reason ?? null,
|
|
254
|
+
usage: result?.usage
|
|
255
|
+
? { inputTokens: result.usage.input_tokens ?? 0, outputTokens: result.usage.output_tokens ?? 0 }
|
|
256
|
+
: null,
|
|
257
|
+
next,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=agent-run.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body for a PUT that replaces the whole row: the stored resource as its GET returned it, with
|
|
3
|
+
* every change the caller gave written over it. A change that is `undefined` was not given and keeps
|
|
4
|
+
* the stored value; the backend would store it as null otherwise (#1195). Server-managed fields in
|
|
5
|
+
* `current` (a dataset's or prompt's latestVersionId) go back as read, which is what keeps them.
|
|
6
|
+
*/
|
|
7
|
+
export declare function overlay<T extends object>(current: T, changes: Record<string, unknown>): T;
|
|
8
|
+
/** Refuses a name that is present but blank, which a full-replace PUT would otherwise store. */
|
|
9
|
+
export declare function assertNameNotBlank(name: string | undefined): void;
|
|
10
|
+
//# sourceMappingURL=overlay.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body for a PUT that replaces the whole row: the stored resource as its GET returned it, with
|
|
3
|
+
* every change the caller gave written over it. A change that is `undefined` was not given and keeps
|
|
4
|
+
* the stored value; the backend would store it as null otherwise (#1195). Server-managed fields in
|
|
5
|
+
* `current` (a dataset's or prompt's latestVersionId) go back as read, which is what keeps them.
|
|
6
|
+
*/
|
|
7
|
+
export function overlay(current, changes) {
|
|
8
|
+
const body = { ...current };
|
|
9
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
10
|
+
if (value !== undefined)
|
|
11
|
+
body[key] = value;
|
|
12
|
+
}
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
/** Refuses a name that is present but blank, which a full-replace PUT would otherwise store. */
|
|
16
|
+
export function assertNameNotBlank(name) {
|
|
17
|
+
if (name !== undefined && !name.trim())
|
|
18
|
+
throw new Error("name must not be empty.");
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=overlay.js.map
|
package/dist/tools/agents.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatErrorForMcp } from "../errors.js";
|
|
3
|
+
import { continueWithDecisions, fetchPendingApprovals, planDecisions, resolveAgentId, splitAgentRef } from "../lib/agent-decide.js";
|
|
4
|
+
import { buildRunEnvelope, chatUrlFor, MODE_RULE } from "../lib/agent-run.js";
|
|
5
|
+
import { assertNameNotBlank } from "../lib/overlay.js";
|
|
3
6
|
const modelSchema = z
|
|
4
7
|
.string()
|
|
5
8
|
.min(1)
|
|
@@ -157,9 +160,9 @@ export function register(server, client) {
|
|
|
157
160
|
}
|
|
158
161
|
});
|
|
159
162
|
// ── update_agent ────────────────────────────────────────────────────────
|
|
160
|
-
server.tool("2kw_update_agent", "Update an agent's metadata and configuration. name
|
|
163
|
+
server.tool("2kw_update_agent", "Update an agent's metadata and configuration. Omitted fields keep their current values (the tool reads the agent's name and description first when either is omitted). `model` or `models` replaces the stored model list (a shorter list leaves no tail); omit both to keep the current models. Does not create a version.", {
|
|
161
164
|
agentId: z.string().describe("The agent ID"),
|
|
162
|
-
name: z.string().
|
|
165
|
+
name: z.string().optional().describe("New agent name"),
|
|
163
166
|
model: modelSchema.optional(),
|
|
164
167
|
models: modelsSchema.optional(),
|
|
165
168
|
description: z.string().optional().describe("Agent description"),
|
|
@@ -170,6 +173,18 @@ export function register(server, client) {
|
|
|
170
173
|
hitlPolicy: z.unknown().optional().describe("Human-in-the-loop approval policy (JSON object)"),
|
|
171
174
|
}, async ({ agentId, name, model, models, description, instructions, options, tools, skills, hitlPolicy }) => {
|
|
172
175
|
try {
|
|
176
|
+
assertNameNotBlank(name);
|
|
177
|
+
// PUT /v1/agents/{id} keeps the configuration fields a body omits, but name is required and
|
|
178
|
+
// description is written as sent, so an omitted description would be cleared (#1195).
|
|
179
|
+
if (name === undefined || description === undefined) {
|
|
180
|
+
const { data: current } = await client.GET("/v1/agents/{id}", {
|
|
181
|
+
params: { path: { id: agentId } },
|
|
182
|
+
});
|
|
183
|
+
if (!current)
|
|
184
|
+
throw new Error(`Agent ${agentId} could not be read.`);
|
|
185
|
+
name ??= current.name;
|
|
186
|
+
description ??= current.description;
|
|
187
|
+
}
|
|
173
188
|
const body = { name, ...modelFields(model, models, false) };
|
|
174
189
|
if (description !== undefined)
|
|
175
190
|
body.description = description;
|
|
@@ -301,6 +316,30 @@ export function register(server, client) {
|
|
|
301
316
|
};
|
|
302
317
|
}
|
|
303
318
|
});
|
|
319
|
+
// ── list_agent_skills ───────────────────────────────────────────────────
|
|
320
|
+
server.tool("2kw_list_agent_skills", "List the skills an agent's latest published version binds, in authored order. This is the "
|
|
321
|
+
+ "read a chat-only USER key may perform; 2kw_get_latest_agent_version carries the full version "
|
|
322
|
+
+ "and needs VIEWER or above. An agent with no published version lists none.", { agentId: z.string().describe("The agent ID") }, async ({ agentId }) => {
|
|
323
|
+
try {
|
|
324
|
+
const { data } = await client.GET("/v1/agents/{agentId}/skills", {
|
|
325
|
+
params: { path: { agentId } },
|
|
326
|
+
});
|
|
327
|
+
const lines = (data ?? []).map((s) => {
|
|
328
|
+
const plugin = s.pluginName ? `, plugin: ${s.pluginName}` : "";
|
|
329
|
+
const description = s.description ? ` — ${s.description}` : "";
|
|
330
|
+
return `- ${s.name} (v${s.versionNumber}, ref: ${s.ref}${plugin})${description}`;
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: "text", text: `Agent skills:\n${lines.join("\n") || "(none)"}` }],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return {
|
|
338
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
339
|
+
isError: true,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
});
|
|
304
343
|
// ── get_agent_version ───────────────────────────────────────────────────
|
|
305
344
|
server.tool("2kw_get_agent_version", "Retrieve a specific version of an agent.", {
|
|
306
345
|
agentId: z.string().describe("The agent ID"),
|
|
@@ -333,12 +372,16 @@ export function register(server, client) {
|
|
|
333
372
|
.string()
|
|
334
373
|
.optional()
|
|
335
374
|
.describe("Resolve this installation's module tool catalog, the way a run of that installation would. Omit to answer for the agent's own tools only."),
|
|
336
|
-
|
|
375
|
+
mode: z
|
|
376
|
+
.enum(["plan", "ask", "auto"])
|
|
377
|
+
.optional()
|
|
378
|
+
.describe("Answer as a conversation in this mode would be gated: plan refuses every call that is not read-only, ask puts a person where the policy would ask the judge, auto is the policy as written. A mode that contributed is listed in matchedRules as conversation_mode.<mode>. Omit for no mode."),
|
|
379
|
+
}, async ({ agentId, versionId, tool, installationId, mode }) => {
|
|
337
380
|
try {
|
|
338
381
|
const { data } = await client.GET("/v1/agents/{agentId}/versions/{versionId}/policy", {
|
|
339
382
|
params: {
|
|
340
383
|
path: { agentId, versionId },
|
|
341
|
-
query: { tool, installation: installationId },
|
|
384
|
+
query: { tool, installation: installationId, mode },
|
|
342
385
|
},
|
|
343
386
|
});
|
|
344
387
|
return {
|
|
@@ -503,6 +546,51 @@ export function register(server, client) {
|
|
|
503
546
|
};
|
|
504
547
|
}
|
|
505
548
|
});
|
|
549
|
+
// ── decide_agent_approvals ──────────────────────────────────────────────
|
|
550
|
+
server.tool("2kw_decide_agent_approvals", "Answer the tool approvals a paused agent run is waiting for (a 2kw_create_response envelope with status requires_approval), then continue the run. "
|
|
551
|
+
+ "Decide only what the user decided: show them each pending approval (tool, arguments, policy class) first and never approve on your own. "
|
|
552
|
+
+ "Every pending approval of the response must be decided in this one call. Pass `agent` exactly as the run was started (keep '@label' and '#model'). "
|
|
553
|
+
+ "Returns the continuation's run envelope, which can pause again: for approval, or on a connector the user must connect "
|
|
554
|
+
+ "in chat.2kw.ai → Connectors (`pendingConnections`; follow its `next`)."
|
|
555
|
+
+ " " + MODE_RULE, {
|
|
556
|
+
agent: z.string().min(1).describe("Agent id or name as the run used it: 'ref[@label][#model]'"),
|
|
557
|
+
responseId: z.string().min(1).describe("The paused response's id (envelope `responseId`)"),
|
|
558
|
+
decisions: z
|
|
559
|
+
.array(z.object({
|
|
560
|
+
approvalId: z.string().min(1).describe("Envelope `pendingApprovals[].approvalId`"),
|
|
561
|
+
decision: z.enum(["approve", "reject"]),
|
|
562
|
+
reason: z.string().optional().describe("Stored with the decision; the model sees it on a reject"),
|
|
563
|
+
remember: z.boolean().optional().describe("Approve this tool for the rest of the conversation; not on destructive tools"),
|
|
564
|
+
}))
|
|
565
|
+
.optional()
|
|
566
|
+
.describe("One entry per pending approval. Mutually exclusive with `decideAll`."),
|
|
567
|
+
decideAll: z.enum(["approve", "reject"]).optional().describe("Decide every pending approval the same way"),
|
|
568
|
+
reason: z.string().optional().describe("Only with `decideAll`: reason stored on every decision"),
|
|
569
|
+
remember: z.boolean().optional().describe("Only with `decideAll`: remember every approval for the conversation"),
|
|
570
|
+
mode: z
|
|
571
|
+
.enum(["plan", "ask", "auto"])
|
|
572
|
+
.optional()
|
|
573
|
+
.describe("The conversation mode this continuation and later turns run under. Only on the user's explicit request."),
|
|
574
|
+
}, async ({ agent, responseId, decisions, decideAll, reason, remember, mode }) => {
|
|
575
|
+
try {
|
|
576
|
+
if (decisions !== undefined && (reason !== undefined || remember !== undefined)) {
|
|
577
|
+
throw new Error("With `decisions`, give `reason` and `remember` per entry.");
|
|
578
|
+
}
|
|
579
|
+
const { name, label, model } = splitAgentRef(agent);
|
|
580
|
+
const agentId = await resolveAgentId(client, name);
|
|
581
|
+
const pending = await fetchPendingApprovals(client, agentId, responseId);
|
|
582
|
+
const items = planDecisions(pending, { decisions, decideAll, reason, remember }, responseId);
|
|
583
|
+
const result = await continueWithDecisions(client, agentId, responseId, items, label, model, mode);
|
|
584
|
+
const envelope = buildRunEnvelope(result, agent, chatUrlFor(client._config?.baseUrl));
|
|
585
|
+
return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] };
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
return {
|
|
589
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
590
|
+
isError: true,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
});
|
|
506
594
|
// ── list_agent_tool_catalogs ─────────────────────────────────────────────
|
|
507
595
|
server.tool("2kw_list_agent_tool_catalogs", "List an agent's tool-catalog sync history, newest first. Optionally narrow to one installation. The signed bytes and their signature are never returned.", {
|
|
508
596
|
agentId: z.string().describe("The agent ID"),
|
package/dist/tools/ai-gateway.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatErrorForMcp } from "../errors.js";
|
|
3
|
+
import { buildRunEnvelope, chatUrlFor, MODE_RULE, modeItem } from "../lib/agent-run.js";
|
|
3
4
|
/**
|
|
4
5
|
* The `model` value for an agent run: `agent/<ref>[@<label>]`, plus `#<model>` when the
|
|
5
6
|
* request switches to another entry of the version's `models` list (#626, spec #591 §4.1).
|
|
@@ -92,7 +93,7 @@ export function register(server, client) {
|
|
|
92
93
|
}
|
|
93
94
|
});
|
|
94
95
|
// ── create_response ─────────────────────────────────────────────────────
|
|
95
|
-
server.tool("2kw_create_response", "Send a request through Backbone's OpenAI-compatible OpenResponses endpoint (POST /v1/responses). Model format: 'provider/model' for a direct gateway call via `model`, or use `agent` to invoke a stored agent by id or name (optionally 'ref@label', e.g. 'support-bot@latest'). With `agent`, `agentModel` runs this one request on another model from the agent version's `models` list.
|
|
96
|
+
server.tool("2kw_create_response", "Send a request through Backbone's OpenAI-compatible OpenResponses endpoint (POST /v1/responses). Model format: 'provider/model' for a direct gateway call via `model`, or use `agent` to invoke a stored agent by id or name (optionally 'ref@label', e.g. 'support-bot@latest'). With `agent`, `agentModel` runs this one request on another model from the agent version's `models` list. With `agent`, the reply is the run envelope as JSON (status, text, tool calls, pending approvals, response id, next step) followed by the model that answered; answer an approval pause with 2kw_decide_agent_approvals. A run that needs the user's own sign-in to a connector pauses with status requires_tool_output and `pendingConnections` (label, host, reason): no tool here can answer that; tell the user to connect it in chat.2kw.ai → Connectors, as the envelope's `next` says. Always non-streaming. " + MODE_RULE, {
|
|
96
97
|
input: z.string().min(1).describe("The input text (sent as a single user message)"),
|
|
97
98
|
agent: z
|
|
98
99
|
.string()
|
|
@@ -108,7 +109,11 @@ export function register(server, client) {
|
|
|
108
109
|
.optional()
|
|
109
110
|
.describe("Model identifier in 'provider/model' format. Mutually exclusive with `agent`."),
|
|
110
111
|
conversation: z.string().optional().describe("Conversation ID this response belongs to"),
|
|
111
|
-
|
|
112
|
+
mode: z
|
|
113
|
+
.enum(["plan", "ask", "auto"])
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Only with `agent`: the conversation mode for this and later turns (plan, ask or auto). Only on the user's explicit request."),
|
|
116
|
+
}, async ({ input, agent, agentModel, model, conversation, mode }) => {
|
|
112
117
|
try {
|
|
113
118
|
if (agent && model) {
|
|
114
119
|
throw new Error("Use either `agent` or `model`, not both.");
|
|
@@ -122,13 +127,16 @@ export function register(server, client) {
|
|
|
122
127
|
if (agentModel !== undefined && agent?.includes("#")) {
|
|
123
128
|
throw new Error("Give the model switch once: either 'ref#model' in `agent` or `agentModel`.");
|
|
124
129
|
}
|
|
130
|
+
if (mode !== undefined && !agent) {
|
|
131
|
+
throw new Error("`mode` applies to agent runs; it needs `agent`.");
|
|
132
|
+
}
|
|
125
133
|
const resolvedModel = agentModelReference(agent, agentModel) ?? model;
|
|
126
134
|
const body = {
|
|
127
135
|
model: resolvedModel,
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// the
|
|
131
|
-
input,
|
|
136
|
+
// Without `mode` the wire `input` is the bare string (shorthand for one user message; see
|
|
137
|
+
// ResponseItemInputDeserializer). With it, the backbone:mode item follows the message (#656);
|
|
138
|
+
// that literal item array is what the cast below is for.
|
|
139
|
+
input: mode ? [{ type: "message", role: "user", content: input }, modeItem(mode)] : input,
|
|
132
140
|
stream: false,
|
|
133
141
|
...(conversation !== undefined && { conversation }),
|
|
134
142
|
};
|
|
@@ -137,6 +145,17 @@ export function register(server, client) {
|
|
|
137
145
|
body: body,
|
|
138
146
|
});
|
|
139
147
|
const result = data;
|
|
148
|
+
if (agent) {
|
|
149
|
+
// The reference the run used, so the envelope's `next` keeps its label and model (D11).
|
|
150
|
+
const agentRef = agentModel !== undefined ? `${agent}#${agentModel}` : agent;
|
|
151
|
+
const envelope = buildRunEnvelope(result, agentRef, chatUrlFor(client._config?.baseUrl));
|
|
152
|
+
const content = [{ type: "text", text: JSON.stringify(envelope, null, 2) }];
|
|
153
|
+
// The echoed model is 'agent/<name>@<version>', plus '#<model>' when the request switched it (#591).
|
|
154
|
+
if (typeof result.model === "string") {
|
|
155
|
+
content.push({ type: "text", text: `[model: ${result.model}]` });
|
|
156
|
+
}
|
|
157
|
+
return { content };
|
|
158
|
+
}
|
|
140
159
|
const parts = [];
|
|
141
160
|
const output = result.output;
|
|
142
161
|
for (const item of output ?? []) {
|
|
@@ -162,11 +181,6 @@ export function register(server, client) {
|
|
|
162
181
|
if (parts.length === 0) {
|
|
163
182
|
parts.push({ type: "text", text: JSON.stringify(result, null, 2) });
|
|
164
183
|
}
|
|
165
|
-
// The response echoes the model that answered: for an agent run
|
|
166
|
-
// 'agent/<name>@<version>', plus '#<model>' when the request switched it (#591).
|
|
167
|
-
if (agent && typeof result.model === "string") {
|
|
168
|
-
parts.push({ type: "text", text: `[model: ${result.model}]` });
|
|
169
|
-
}
|
|
170
184
|
return { content: parts };
|
|
171
185
|
}
|
|
172
186
|
catch (error) {
|
|
@@ -134,6 +134,32 @@ export function register(server, client) {
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
});
|
|
137
|
+
// ── cancel_conversation_turn ─────────────────────────────────────────────
|
|
138
|
+
server.tool("2kw_cancel_conversation_turn", "Ask a running turn of a conversation to stop. turnId is the value the client sent as the Backbone-Turn-Id header on the responses call. The stop is accepted, not confirmed: the turn's own response reports status cancelled, or completed when the stop arrived too late.", {
|
|
139
|
+
conversationId: z.string().describe("The conversation ID"),
|
|
140
|
+
turnId: z
|
|
141
|
+
.string()
|
|
142
|
+
.regex(/^[A-Za-z0-9_-]{1,64}$/)
|
|
143
|
+
.describe("The turn id from the Backbone-Turn-Id header: 1-64 characters of A-Z, a-z, 0-9, '_' or '-'"),
|
|
144
|
+
}, async ({ conversationId, turnId }) => {
|
|
145
|
+
try {
|
|
146
|
+
await client.POST("/v1/conversations/{conversationId}/cancel", {
|
|
147
|
+
params: { path: { conversationId } },
|
|
148
|
+
body: { turn_id: turnId },
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text", text: `Stop requested for turn ${turnId} of conversation ${conversationId}.` },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
});
|
|
137
163
|
// ── list_conversation_items ──────────────────────────────────────────────
|
|
138
164
|
server.tool("2kw_list_conversation_items", "Fetch a conversation's items in replay order, oldest first.", { conversationId: z.string().describe("The conversation ID") }, async ({ conversationId }) => {
|
|
139
165
|
try {
|
package/dist/tools/datasets.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatErrorForMcp } from "../errors.js";
|
|
3
|
+
import { assertNameNotBlank, overlay } from "../lib/overlay.js";
|
|
3
4
|
async function resolveLatestVersionId(client, datasetId) {
|
|
4
5
|
const { data } = await client.GET("/v1/datasets/{id}/versions/latest", { params: { path: { id: datasetId } } });
|
|
5
6
|
const versionId = data?.id;
|
|
@@ -104,7 +105,7 @@ export function register(server, client) {
|
|
|
104
105
|
}
|
|
105
106
|
});
|
|
106
107
|
// ── update_dataset ─────────────────────────────────────────────
|
|
107
|
-
server.tool("2kw_update_dataset", "Update an existing dataset's details.", {
|
|
108
|
+
server.tool("2kw_update_dataset", "Update an existing dataset's details. Omitted fields keep their current values (the tool reads the dataset first).", {
|
|
108
109
|
datasetId: z.string().describe("The dataset ID"),
|
|
109
110
|
name: z.string().optional().describe("New name"),
|
|
110
111
|
description: z.string().optional().describe("New description"),
|
|
@@ -114,22 +115,16 @@ export function register(server, client) {
|
|
|
114
115
|
metadata: z.unknown().optional().describe("Arbitrary metadata (JSON object)"),
|
|
115
116
|
}, async ({ datasetId, name, description, type, inputSchema, expectedOutputSchema, metadata }) => {
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
if (inputSchema !== undefined)
|
|
125
|
-
body.inputSchema = inputSchema;
|
|
126
|
-
if (expectedOutputSchema !== undefined)
|
|
127
|
-
body.expectedOutputSchema = expectedOutputSchema;
|
|
128
|
-
if (metadata !== undefined)
|
|
129
|
-
body.metadata = metadata;
|
|
118
|
+
assertNameNotBlank(name);
|
|
119
|
+
// PUT /v1/datasets/{id} replaces the whole dataset, so an omitted field would be stored as null.
|
|
120
|
+
const { data: current } = await client.GET("/v1/datasets/{id}", {
|
|
121
|
+
params: { path: { id: datasetId } },
|
|
122
|
+
});
|
|
123
|
+
if (!current)
|
|
124
|
+
throw new Error(`Dataset ${datasetId} could not be read.`);
|
|
130
125
|
const { data } = await client.PUT("/v1/datasets/{id}", {
|
|
131
126
|
params: { path: { id: datasetId } },
|
|
132
|
-
body:
|
|
127
|
+
body: overlay(current, { name, description, type, inputSchema, expectedOutputSchema, metadata }),
|
|
133
128
|
});
|
|
134
129
|
return {
|
|
135
130
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|