Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/sampling-handler.ts
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

269 lines
8.6 KiB
TypeScript

import { complete, type Api, type AssistantMessage, type Message, type Model, type TextContent } from "@earendil-works/pi-ai";
import { truncateAtWord } from "./utils.ts";
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
CreateMessageRequestSchema,
type CreateMessageRequest,
type CreateMessageResult,
type ModelPreferences,
type SamplingMessage,
type SamplingMessageContentBlock,
} from "@modelcontextprotocol/sdk/types.js";
export interface SamplingHandlerOptions {
serverName: string;
autoApprove: boolean;
ui?: ExtensionUIContext;
modelRegistry: ModelRegistry;
getCurrentModel: () => Model<Api> | undefined;
getSignal: () => AbortSignal | undefined;
}
export type ServerSamplingConfig = Omit<SamplingHandlerOptions, "serverName">;
export function registerSamplingHandler(client: Client, options: SamplingHandlerOptions): void {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return handleSamplingRequest(options, request as CreateMessageRequest);
});
}
export async function handleSamplingRequest(
options: SamplingHandlerOptions,
request: CreateMessageRequest,
): Promise<CreateMessageResult> {
const params = request.params;
if ("task" in params && params.task) {
throw new Error("MCP sampling tasks are not supported");
}
if (params.includeContext && params.includeContext !== "none") {
throw new Error("MCP sampling context inclusion is not supported");
}
if (params.tools?.length) {
throw new Error("MCP sampling tool use is not supported");
}
if (params.toolChoice) {
throw new Error("MCP sampling tool choice is not supported");
}
if (params.stopSequences?.length) {
throw new Error("MCP sampling stop sequences are not supported");
}
const messages = params.messages.map(convertSamplingMessage);
const { model, apiKey, headers } = await resolveSamplingModel(options, params.modelPreferences);
await confirmSampling(
options,
"Approve MCP sampling request",
formatRequestApproval(options.serverName, `${model.provider}/${model.id}`, params.systemPrompt, messages),
);
const result = await complete(
model,
{
systemPrompt: params.systemPrompt,
messages,
},
{
apiKey,
headers,
maxTokens: params.maxTokens,
temperature: params.temperature,
metadata: params.metadata as Record<string, unknown> | undefined,
signal: options.getSignal(),
},
);
const converted = convertAssistantResult(result);
await confirmSampling(
options,
"Return MCP sampling response",
formatResponseApproval(options.serverName, converted),
);
return converted;
}
function formatRequestApproval(
serverName: string,
modelName: string,
systemPrompt: string | undefined,
messages: Message[],
): string {
const lines = [`${serverName} wants to sample ${messages.length} message${messages.length === 1 ? "" : "s"} with ${modelName}.`];
if (systemPrompt) {
lines.push(`System: ${truncateAtWord(systemPrompt, 400)}`);
}
for (const [index, message] of messages.entries()) {
lines.push(`${index + 1}. ${message.role}: ${truncateAtWord(messageText(message), 400)}`);
}
return lines.join("\n\n");
}
function formatResponseApproval(serverName: string, response: CreateMessageResult): string {
const text = response.content.type === "text" ? response.content.text : `[${response.content.type} content]`;
return `${serverName} will receive this response from ${response.model}:\n\n${truncateAtWord(text, 1000)}`;
}
function messageText(message: Message): string {
if (typeof message.content === "string") return message.content;
return message.content.map((block) => {
if (block.type === "text") return block.text;
if (block.type === "image") return `[image: ${block.mimeType}]`;
if (block.type === "thinking") return "[thinking]";
if (block.type === "toolCall") return `[tool call: ${block.name}]`;
return "[content]";
}).join("\n");
}
async function resolveSamplingModel(
options: SamplingHandlerOptions,
modelPreferences: ModelPreferences | undefined,
): Promise<{
model: Model<Api>;
apiKey?: string;
headers?: Record<string, string>;
}> {
const candidates: Model<Api>[] = [];
const availableModels = options.modelRegistry.getAvailable();
for (const hint of modelPreferences?.hints ?? []) {
const normalizedHint = hint.name?.trim().toLowerCase();
if (!normalizedHint) continue;
for (const model of availableModels) {
const searchableNames = [`${model.provider}/${model.id}`, model.id, model.name];
if (searchableNames.some((name) => name.toLowerCase().includes(normalizedHint))) {
addSamplingCandidate(candidates, model);
}
}
}
const currentModel = options.getCurrentModel();
if (currentModel) addSamplingCandidate(candidates, currentModel);
for (const model of availableModels) {
addSamplingCandidate(candidates, model);
}
const errors: string[] = [];
for (const model of candidates) {
const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
if (auth.ok === false) {
errors.push(`${model.provider}/${model.id}: ${auth.error}`);
continue;
}
return { model, apiKey: auth.apiKey, headers: auth.headers };
}
if (errors.length > 0) {
throw new Error(`No configured auth for MCP sampling model. ${errors.join("; ")}`);
}
throw new Error("No Pi model is available for MCP sampling");
}
function addSamplingCandidate(candidates: Model<Api>[], model: Model<Api>): void {
if (!candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
candidates.push(model);
}
}
async function confirmSampling(options: SamplingHandlerOptions, title: string, message: string): Promise<void> {
if (options.autoApprove) return;
if (!options.ui) {
throw new Error("MCP sampling requires interactive approval. Set settings.samplingAutoApprove to true to allow it without UI.");
}
const approved = await options.ui.confirm(title, message);
if (!approved) {
throw new Error("MCP sampling request was declined");
}
}
function convertSamplingMessage(message: SamplingMessage): Message {
const blocks = Array.isArray(message.content) ? message.content : [message.content];
if (message.role === "user") {
return {
role: "user",
content: blocks.map(convertUserContent),
timestamp: Date.now(),
};
}
return {
role: "assistant",
content: blocks.map(convertAssistantContent),
api: "mcp-sampling",
provider: "mcp",
model: "sampling-request",
usage: zeroUsage(),
stopReason: "stop",
timestamp: Date.now(),
};
}
function convertUserContent(block: SamplingMessageContentBlock): TextContent {
if (block.type === "text") {
return { type: "text", text: block.text };
}
throw new Error(`MCP sampling ${block.type} content is not supported`);
}
function convertAssistantContent(block: SamplingMessageContentBlock): TextContent {
if (block.type === "text") {
return { type: "text", text: block.text };
}
throw new Error(`MCP sampling assistant ${block.type} content is not supported`);
}
function convertAssistantResult(message: AssistantMessage): CreateMessageResult {
if (message.stopReason === "error") {
throw new Error(message.errorMessage ?? "MCP sampling model call failed");
}
if (message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? "MCP sampling model call was aborted");
}
const text = message.content
.map((block) => {
if (block.type === "text") return block.text;
if (block.type === "thinking") return undefined;
throw new Error(`MCP sampling result ${block.type} content is not supported`);
})
.filter((value): value is string => value !== undefined)
.join("\n\n")
.trim();
if (!text) {
throw new Error("MCP sampling result did not contain text content");
}
return {
role: "assistant",
content: { type: "text", text },
model: `${message.provider}/${message.model}`,
stopReason: mapStopReason(message.stopReason),
};
}
function mapStopReason(reason: AssistantMessage["stopReason"]): CreateMessageResult["stopReason"] {
if (reason === "stop") return "endTurn";
if (reason === "length") return "maxTokens";
if (reason === "toolUse") return "toolUse";
return reason;
}
function zeroUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}