mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-09-21 06:09:39 +08:00
fix(mcp-server): mcp use climc struct (#25184)
This commit is contained in:
@@ -304,10 +304,12 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
|
||||
return formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// 工具调用场景下单行 SSE 可能很大,提高 buffer
|
||||
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -394,7 +396,7 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent,
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
|
||||
return nil, formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body)
|
||||
}
|
||||
|
||||
var chatResp OpenAIChatResponse
|
||||
@@ -511,6 +513,57 @@ func (o *openai) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool {
|
||||
return tools
|
||||
}
|
||||
|
||||
// formatLLMHTTPError 把上游 JSON 错误整理成可读单行提示。
|
||||
func formatLLMHTTPError(status int, model string, body []byte) error {
|
||||
msg := extractLLMErrorMessage(body)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(body))
|
||||
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||
msg = strings.Join(strings.Fields(msg), " ")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(status)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "unsupported model"):
|
||||
return errors.Errorf("模型不支持:当前配置为 %q(%s)", model, msg)
|
||||
case status == http.StatusUnauthorized || strings.Contains(lower, "invalid api key") || strings.Contains(lower, "incorrect api key"):
|
||||
return errors.Errorf("鉴权失败:请检查 Agent 的 API Key(%s)", msg)
|
||||
case status == http.StatusTooManyRequests:
|
||||
return errors.Errorf("请求过于频繁,请稍后重试(%s)", msg)
|
||||
default:
|
||||
return errors.Errorf("大模型接口返回 %d:%s", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func extractLLMErrorMessage(body []byte) string {
|
||||
body = bytes.TrimSpace(body)
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
if errObj, ok := payload["error"].(map[string]interface{}); ok {
|
||||
if m, ok := errObj["message"].(string); ok && strings.TrimSpace(m) != "" {
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
if m, ok := errObj["msg"].(string); ok && strings.TrimSpace(m) != "" {
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
}
|
||||
if m, ok := payload["message"].(string); ok && strings.TrimSpace(m) != "" {
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
if m, ok := payload["msg"].(string); ok && strings.TrimSpace(m) != "" {
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Structures
|
||||
|
||||
type OpenAIChatMessage struct {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package llm_client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatLLMHTTPErrorUnsupportedModel(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"error": {
|
||||
"code": "400",
|
||||
"message": "Unsupported model MiMo-V2.5"
|
||||
}
|
||||
}`)
|
||||
err := formatLLMHTTPError(http.StatusBadRequest, "MiMo-V2.5", body)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "模型不支持") {
|
||||
t.Fatalf("want 模型不支持, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "MiMo-V2.5") {
|
||||
t.Fatalf("want model name in message, got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "\n") {
|
||||
t.Fatalf("error should be single line, got %q", msg)
|
||||
}
|
||||
}
|
||||
+224
-127
@@ -199,14 +199,20 @@ func (mcp *SMCPAgent) GetApiKey() (string, error) {
|
||||
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
|
||||
man.SSharableVirtualResourceBaseManager.CustomizeHandlerInfo(info)
|
||||
|
||||
// log.Infoln("query name of handler info", info.GetName(nil))
|
||||
|
||||
switch info.GetName(nil) {
|
||||
case "get_specific":
|
||||
info.SetProcessTimeout(time.Hour * 4).SetWorkerManager(mcpAgentWorkerMan)
|
||||
}
|
||||
}
|
||||
|
||||
func (man *SMCPAgentManager) SetHandlerProcessTimeout(info *appsrv.SHandlerInfo, r *http.Request) time.Duration {
|
||||
// 仅 llm 侧 mcp_agents/*/chat-stream
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "chat-stream") {
|
||||
return 4 * time.Hour
|
||||
}
|
||||
return man.SSharableVirtualResourceBaseManager.SetHandlerProcessTimeout(info, r)
|
||||
}
|
||||
|
||||
func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.MCPAgentCreateInput) (*api.MCPAgentCreateInput, error) {
|
||||
var err error
|
||||
input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput)
|
||||
@@ -472,9 +478,12 @@ func (mcp *SMCPAgent) PerformChatStream(
|
||||
}
|
||||
|
||||
w := appParams.Response
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Content-Encoding", "identity")
|
||||
appParams.OverrideResponseBodyWrapper = true
|
||||
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
@@ -482,29 +491,67 @@ func (mcp *SMCPAgent) PerformChatStream(
|
||||
return nil, errors.Error("Streaming unsupported!")
|
||||
}
|
||||
|
||||
// 立刻推一条注释帧,避免 ListTools/首轮推理期间前端只看到「思考中」
|
||||
if _, err := fmt.Fprintf(w, ": connected\n\n"); err == nil {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
_, err := mcp.process(ctx, userCred, &input, func(content string) error {
|
||||
if len(content) > 0 {
|
||||
for line := range strings.SplitSeq(content, "\n") {
|
||||
fmt.Fprintf(w, "data: %s\n", line)
|
||||
}
|
||||
fmt.Fprintf(w, "\n")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 单个 SSE 事件:多行 content 用多条 data: 表示(前端按事件拼接为 \n)
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "data: Error: %v\n\n", err)
|
||||
// 单行推送,避免换行 JSON 被 SSE 截断;去掉冗长 wrap 前缀
|
||||
msg := friendlyChatStreamError(err)
|
||||
fmt.Fprintf(w, "data: Error: %s\n\n", msg)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// process 处理用户请求
|
||||
// friendlyChatStreamError 面向用户的短错误文案(单行,适合 SSE)。
|
||||
func friendlyChatStreamError(err error) string {
|
||||
if err == nil {
|
||||
return "未知错误"
|
||||
}
|
||||
msg := err.Error()
|
||||
// 去掉 "chat stream round N: " 包装,突出真正原因
|
||||
const wrap = "chat stream round "
|
||||
if i := strings.Index(msg, wrap); i >= 0 {
|
||||
rest := msg[i+len(wrap):]
|
||||
if j := strings.Index(rest, ": "); j >= 0 {
|
||||
msg = rest[j+2:]
|
||||
}
|
||||
}
|
||||
msg = strings.ReplaceAll(msg, "\r\n", " ")
|
||||
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||
return strings.Join(strings.Fields(msg), " ")
|
||||
}
|
||||
|
||||
// process 处理用户请求(多轮工具调用,直到模型不再发 tool_calls 或达到上限)
|
||||
func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) {
|
||||
// 获取 MCP Server 的工具列表
|
||||
mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetMcpServerUrl")
|
||||
@@ -516,128 +563,143 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
|
||||
return nil, errors.Wrap(err, "list MCP tools")
|
||||
}
|
||||
log.Infof("Got %d tools from MCP Server", len(mcpTools))
|
||||
if onStream != nil {
|
||||
_ = onStream("正在准备…\n")
|
||||
}
|
||||
|
||||
// get llmClient
|
||||
llmClient := mcp.GetLLMClientDriver()
|
||||
if llmClient == nil {
|
||||
return nil, errors.Error("failed to get LLM client driver")
|
||||
}
|
||||
|
||||
tools := llmClient.ConvertMCPTools(mcpTools)
|
||||
|
||||
// 构建系统提示词
|
||||
systemPrompt := buildSystemPrompt()
|
||||
|
||||
// 初始化消息历史
|
||||
messages := make([]ILLMChatMessage, 0)
|
||||
messages = append(messages, llmClient.NewSystemMessage(systemPrompt))
|
||||
|
||||
// 处理历史消息
|
||||
messages = append(messages, llmClient.NewSystemMessage(buildSystemPrompt()))
|
||||
if len(req.History) > 0 {
|
||||
historyMessages := processHistoryMessages(
|
||||
messages = append(messages, processHistoryMessages(
|
||||
req.History,
|
||||
llmClient,
|
||||
options.Options.MCPAgentUserCharLimit,
|
||||
options.Options.MCPAgentAssistantCharLimit,
|
||||
)
|
||||
messages = append(messages, historyMessages...)
|
||||
)...)
|
||||
}
|
||||
|
||||
messages = append(messages, llmClient.NewUserMessage(req.Message))
|
||||
|
||||
// 记录工具调用
|
||||
var toolCallRecords []api.MCPAgentToolCallRecord
|
||||
|
||||
log.Infof("Phase 1: Thinking & Acting...")
|
||||
|
||||
// 处理流式的工具调用参数
|
||||
type accumToolCall struct {
|
||||
Id string
|
||||
Name string
|
||||
RawArguments strings.Builder
|
||||
maxRounds := options.Options.MCPAgentMaxToolRounds
|
||||
if maxRounds <= 0 {
|
||||
maxRounds = 8
|
||||
}
|
||||
accToolCalls := make(map[int]*accumToolCall)
|
||||
var accumulatedContent strings.Builder
|
||||
var accumulatedReasoning strings.Builder
|
||||
hasToolCalls := false
|
||||
|
||||
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
|
||||
if chunk.HasToolCalls() {
|
||||
hasToolCalls = true
|
||||
for _, tc := range chunk.GetToolCalls() {
|
||||
idx := tc.GetIndex()
|
||||
if _, exists := accToolCalls[idx]; !exists {
|
||||
accToolCalls[idx] = &accumToolCall{
|
||||
Id: tc.GetId(),
|
||||
var toolCallRecords []api.MCPAgentToolCallRecord
|
||||
var finalAnswer strings.Builder
|
||||
nudged := false
|
||||
resourceOp := looksLikeResourceOperation(req.Message)
|
||||
labels := newProgressLabelCache()
|
||||
|
||||
for round := 1; round <= maxRounds; round++ {
|
||||
log.Infof("MCP agent tool round %d/%d", round, maxRounds)
|
||||
|
||||
type accumToolCall struct {
|
||||
Id string
|
||||
Name string
|
||||
RawArguments strings.Builder
|
||||
}
|
||||
accToolCalls := make(map[int]*accumToolCall)
|
||||
var accumulatedContent strings.Builder
|
||||
var accumulatedReasoning strings.Builder
|
||||
hasToolCalls := false
|
||||
// 首轮资源操作可能被 nudge:先不流式,避免把「计划文案」推给用户
|
||||
optimisticStream := !(round == 1 && resourceOp && !nudged && len(tools) > 0)
|
||||
streamedAnswer := false
|
||||
|
||||
// 有 tool_calls 时不推送中间文案;纯文本最终答复边生成边推送
|
||||
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
|
||||
if chunk.HasToolCalls() {
|
||||
hasToolCalls = true
|
||||
for _, tc := range chunk.GetToolCalls() {
|
||||
idx := tc.GetIndex()
|
||||
if _, exists := accToolCalls[idx]; !exists {
|
||||
accToolCalls[idx] = &accumToolCall{Id: tc.GetId()}
|
||||
}
|
||||
atc := accToolCalls[idx]
|
||||
if id := tc.GetId(); id != "" {
|
||||
atc.Id = id
|
||||
}
|
||||
if name := tc.GetFunction().GetName(); name != "" {
|
||||
atc.Name = name
|
||||
}
|
||||
if args := tc.GetFunction().GetRawArguments(); args != "" {
|
||||
atc.RawArguments.WriteString(args)
|
||||
}
|
||||
}
|
||||
|
||||
atc := accToolCalls[idx]
|
||||
if id := tc.GetId(); id != "" {
|
||||
atc.Id = id
|
||||
}
|
||||
if name := tc.GetFunction().GetName(); name != "" {
|
||||
atc.Name = name
|
||||
}
|
||||
if args := tc.GetFunction().GetRawArguments(); args != "" {
|
||||
atc.RawArguments.WriteString(args)
|
||||
}
|
||||
if r := chunk.GetReasoningContent(); len(r) > 0 {
|
||||
accumulatedReasoning.WriteString(r)
|
||||
}
|
||||
if content := chunk.GetContent(); len(content) > 0 {
|
||||
accumulatedContent.WriteString(content)
|
||||
// 尚未出现 tool_calls 时按 token 增量推送,避免最终结果整段一次性返回
|
||||
if onStream != nil && optimisticStream && !hasToolCalls {
|
||||
streamedAnswer = true
|
||||
if err := onStream(content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "chat stream round %d", round)
|
||||
}
|
||||
|
||||
if r := chunk.GetReasoningContent(); len(r) > 0 {
|
||||
accumulatedReasoning.WriteString(r)
|
||||
}
|
||||
|
||||
content := chunk.GetContent()
|
||||
if len(content) > 0 {
|
||||
accumulatedContent.WriteString(content)
|
||||
if onStream != nil {
|
||||
if err := onStream(content); err != nil {
|
||||
return err
|
||||
if !hasToolCalls {
|
||||
answer := accumulatedContent.String()
|
||||
// 首轮对资源操作却不调工具:强制再试一轮,要求发 tool_calls
|
||||
if round == 1 && resourceOp && !nudged && len(tools) > 0 {
|
||||
nudged = true
|
||||
log.Warningf("MCP agent round1 returned no tool_calls for resource op; nudging model")
|
||||
if answer != "" {
|
||||
messages = append(messages, llmClient.NewAssistantMessage(answer))
|
||||
}
|
||||
messages = append(messages, llmClient.NewUserMessage(
|
||||
"请立刻调用合适的 climc_* 工具完成我的请求,不要只描述计划或编造查询结果。若要创建虚拟机,请从 climc_cloud_region_list 开始连续调用直到 climc_server_create。",
|
||||
))
|
||||
continue
|
||||
}
|
||||
// 未走增量推送时的兜底(例如首轮 nudge 关闭了 optimisticStream)
|
||||
if onStream != nil && !streamedAnswer && answer != "" {
|
||||
if err := onStream(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
finalAnswer.WriteString(answer)
|
||||
return &api.MCPAgentResponse{
|
||||
Success: true,
|
||||
Answer: finalAnswer.String(),
|
||||
ToolCalls: toolCallRecords,
|
||||
}, nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "phase 1 chat stream error")
|
||||
}
|
||||
|
||||
// 检查是否有工具调用
|
||||
if !hasToolCalls {
|
||||
// 如果阶段一没有调用工具,直接返回结果
|
||||
return &api.MCPAgentResponse{
|
||||
Success: true,
|
||||
Answer: accumulatedContent.String(),
|
||||
ToolCalls: toolCallRecords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Convert accumulated tool calls to ILLMToolCall
|
||||
var toolCalls []ILLMToolCall
|
||||
// Find max index
|
||||
maxIdx := -1
|
||||
for idx := range accToolCalls {
|
||||
if idx > maxIdx {
|
||||
maxIdx = idx
|
||||
toolCalls := make([]ILLMToolCall, 0)
|
||||
maxIdx := -1
|
||||
for idx := range accToolCalls {
|
||||
if idx > maxIdx {
|
||||
maxIdx = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i <= maxIdx; i++ {
|
||||
if atc, ok := accToolCalls[i]; ok {
|
||||
var args map[string]interface{}
|
||||
for i := 0; i <= maxIdx; i++ {
|
||||
atc, ok := accToolCalls[i]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
args := make(map[string]interface{})
|
||||
rawArgs := atc.RawArguments.String()
|
||||
if len(rawArgs) > 0 {
|
||||
if err := json.Unmarshal([]byte(rawArgs), &args); err != nil {
|
||||
log.Errorf("Failed to unmarshal arguments for tool %s: %v. Raw: %s", atc.Name, err, rawArgs)
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
} else {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, &SLLMToolCall{
|
||||
Id: atc.Id,
|
||||
Function: SLLMFunctionCall{
|
||||
@@ -646,51 +708,75 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
log.Infof("Got %d tool calls from Phase 1", len(toolCalls))
|
||||
log.Infof("Round %d got %d tool calls", round, len(toolCalls))
|
||||
|
||||
toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "process tool calls")
|
||||
records, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient, onStream, labels)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "process tool calls")
|
||||
}
|
||||
toolCallRecords = append(toolCallRecords, records...)
|
||||
messages = append(messages, toolMessages...)
|
||||
}
|
||||
|
||||
// 将工具调用相关的消息加入历史
|
||||
messages = append(messages, toolMessages...)
|
||||
|
||||
log.Infof("Phase 2: Streaming Response...")
|
||||
|
||||
var finalAnswer strings.Builder
|
||||
|
||||
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
|
||||
content := chunk.GetContent()
|
||||
if len(content) > 0 {
|
||||
// 聚合最终答案
|
||||
finalAnswer.WriteString(content)
|
||||
|
||||
// 实时流式输出
|
||||
// 工具轮次用尽后,再给模型一轮纯文本总结(不再传 tools),避免只回“达到上限”而不解释最后一次工具错误
|
||||
log.Infof("MCP agent tool rounds exhausted (%d); requesting final summary without tools", maxRounds)
|
||||
messages = append(messages, llmClient.NewUserMessage(
|
||||
"工具调用轮次已用尽。请根据上述工具返回结果,用中文向用户总结成功或失败原因;若创建失败请说明关键错误(如 sched_fail)与建议,不要再调用工具。",
|
||||
))
|
||||
var summary strings.Builder
|
||||
err = llmClient.ChatStream(ctx, mcp, messages, nil, func(chunk ILLMChatResponse) error {
|
||||
if content := chunk.GetContent(); len(content) > 0 {
|
||||
summary.WriteString(content)
|
||||
if onStream != nil {
|
||||
if err := onStream(content); err != nil {
|
||||
return err
|
||||
}
|
||||
return onStream(content)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "phase 2 stream error")
|
||||
log.Warningf("MCP agent final summary failed: %v", err)
|
||||
msg := fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds)
|
||||
if onStream != nil {
|
||||
_ = onStream(msg)
|
||||
}
|
||||
return &api.MCPAgentResponse{
|
||||
Success: true,
|
||||
Answer: msg,
|
||||
ToolCalls: toolCallRecords,
|
||||
}, nil
|
||||
}
|
||||
answer := strings.TrimSpace(summary.String())
|
||||
if answer == "" {
|
||||
answer = fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds)
|
||||
if onStream != nil {
|
||||
_ = onStream(answer)
|
||||
}
|
||||
}
|
||||
|
||||
return &api.MCPAgentResponse{
|
||||
Success: true,
|
||||
Answer: finalAnswer.String(),
|
||||
Answer: answer,
|
||||
ToolCalls: toolCallRecords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompt 构建系统提示词
|
||||
func looksLikeResourceOperation(msg string) bool {
|
||||
m := strings.ToLower(msg)
|
||||
keys := []string{
|
||||
"创建", "查询", "列出", "列表", "启动", "停止", "重启", "删除", "销毁",
|
||||
"虚拟机", "主机", "镜像", "网络", "区域", "套餐", "规格", "密码",
|
||||
"create", "list", "start", "stop", "restart", "delete", "server", "vm",
|
||||
}
|
||||
for _, k := range keys {
|
||||
if strings.Contains(m, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSystemPrompt 构建系统提示词(平台名来自 BaseOptions.PlatformName,支持热更新)
|
||||
func buildSystemPrompt() string {
|
||||
return api.MCP_AGENT_SYSTEM_PROMPT
|
||||
return fmt.Sprintf(api.MCP_AGENT_SYSTEM_PROMPT, options.ResolvedPlatformName())
|
||||
}
|
||||
|
||||
func processHistoryMessages(
|
||||
@@ -744,6 +830,8 @@ func processToolCalls(
|
||||
reasoningContent, content string,
|
||||
mcpClient *utils.MCPClient,
|
||||
llmClient ILLMClient,
|
||||
onStream func(string) error,
|
||||
labels *progressLabelCache,
|
||||
) ([]api.MCPAgentToolCallRecord, []ILLMChatMessage, error) {
|
||||
toolCallRecords := make([]api.MCPAgentToolCallRecord, 0)
|
||||
messagesToAdd := make([]ILLMChatMessage, 0)
|
||||
@@ -775,6 +863,15 @@ func processToolCalls(
|
||||
Result: resultText,
|
||||
})
|
||||
|
||||
labels.rememberFromTool(toolName, resultText)
|
||||
|
||||
// 向用户流式展示资源选择/查询摘要,而不是工具名
|
||||
if onStream != nil {
|
||||
if progress := summarizeToolProgress(toolName, arguments, resultText, labels); progress != "" {
|
||||
_ = onStream(progress)
|
||||
}
|
||||
}
|
||||
|
||||
// 将工具执行结果加入历史
|
||||
messagesToAdd = append(messagesToAdd, llmClient.NewToolMessage(tc.GetId(), toolName, resultText))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// progressLabelCache 缓存 list 结果中的 id→可读名称,供后续进度文案使用。
|
||||
type progressLabelCache struct {
|
||||
regions map[string]string
|
||||
}
|
||||
|
||||
func newProgressLabelCache() *progressLabelCache {
|
||||
return &progressLabelCache{regions: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (c *progressLabelCache) rememberFromTool(toolName, resultText string) {
|
||||
if c == nil || isToolResultError(resultText) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(toolName, "climc_")
|
||||
if strings.Contains(name, "cloud_region_list") {
|
||||
c.rememberRegions(resultText)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *progressLabelCache) rememberRegions(resultText string) {
|
||||
if c.regions == nil {
|
||||
c.regions = make(map[string]string)
|
||||
}
|
||||
items, _ := extractListItems(resultText)
|
||||
for _, item := range items {
|
||||
id := jsonString(item, "id")
|
||||
name := jsonString(item, "name")
|
||||
if id == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
ext := jsonString(item, "external_id")
|
||||
label := name
|
||||
if ext != "" && !strings.EqualFold(ext, name) {
|
||||
label = fmt.Sprintf("%s(%s)", name, ext)
|
||||
}
|
||||
c.regions[id] = label
|
||||
c.regions[name] = label
|
||||
if ext != "" {
|
||||
c.regions[ext] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *progressLabelCache) regionLabel(idOrName string) string {
|
||||
if idOrName == "" {
|
||||
return ""
|
||||
}
|
||||
if c != nil && c.regions != nil {
|
||||
if v := c.regions[idOrName]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return idOrName
|
||||
}
|
||||
|
||||
// summarizeToolProgress 将工具调用结果整理成面向用户的进度文案(逐项展示资源,而非工具名)。
|
||||
func summarizeToolProgress(toolName string, args map[string]interface{}, resultText string, labels *progressLabelCache) string {
|
||||
name := strings.TrimPrefix(toolName, "climc_")
|
||||
if isToolResultError(resultText) {
|
||||
return fmt.Sprintf("✗ %s失败:%s\n", progressLabel(name), truncateRunes(stripMCPHint(resultText), 180))
|
||||
}
|
||||
|
||||
switch {
|
||||
case name == "docs_search" || strings.HasSuffix(name, "docs_search"):
|
||||
return formatResourceListProgress("文档", resultText, []string{"name", "title", "path"})
|
||||
|
||||
case name == "docs_get" || strings.HasSuffix(name, "docs_get"):
|
||||
path := firstArg(args, "path", "PATH")
|
||||
if path != "" {
|
||||
return fmt.Sprintf("✓ 已阅读文档:%s\n", path)
|
||||
}
|
||||
return "✓ 已阅读文档\n"
|
||||
|
||||
case strings.Contains(name, "cloud_region_capability"):
|
||||
id := firstArg(args, "id", "ID", "name")
|
||||
region := labels.regionLabel(id)
|
||||
types := extractStorageTypeHints(resultText)
|
||||
if region != "" && types != "" {
|
||||
return fmt.Sprintf("✓ 区域 %s 可用磁盘类型:%s\n", region, types)
|
||||
}
|
||||
if types != "" {
|
||||
return fmt.Sprintf("✓ 可用磁盘类型:%s\n", types)
|
||||
}
|
||||
return fmt.Sprintf("✓ 已查询区域能力%s\n", paren(region))
|
||||
|
||||
case strings.Contains(name, "cloud_region_list"):
|
||||
return formatResourceListProgress("区域", resultText, []string{"name", "external_id", "id"})
|
||||
|
||||
case strings.Contains(name, "cached_image_list"), strings.Contains(name, "image_list"):
|
||||
return formatResourceListProgress("镜像", resultText, []string{"name", "os_type", "os_distribution", "id"})
|
||||
|
||||
case strings.Contains(name, "server_sku_list"):
|
||||
return formatResourceListProgress("套餐", resultText, []string{"name", "instance_type_category", "cpu_core_count", "memory_size_mb", "id"})
|
||||
|
||||
case strings.Contains(name, "network_list"):
|
||||
return formatResourceListProgress("网络", resultText, []string{"name", "guest_ip_prefix", "vpc", "id"})
|
||||
|
||||
case strings.Contains(name, "vpc_list"):
|
||||
return formatResourceListProgress("VPC", resultText, []string{"name", "cidr_block", "id"})
|
||||
|
||||
case strings.Contains(name, "storage_list"):
|
||||
return formatResourceListProgress("存储", resultText, []string{"name", "storage_type", "capacity", "id"})
|
||||
|
||||
case strings.Contains(name, "server_list"):
|
||||
return formatResourceListProgress("虚拟机", resultText, []string{"name", "status", "id"})
|
||||
|
||||
case strings.Contains(name, "server_create"):
|
||||
return formatServerCreateProgress(args, resultText, labels)
|
||||
|
||||
case strings.Contains(name, "server_show"):
|
||||
return formatSingleResourceProgress("虚拟机详情", resultText, []string{"name", "status", "id"})
|
||||
|
||||
case strings.HasPrefix(name, "server_"):
|
||||
id := firstArg(args, "id", "ID", "name")
|
||||
action := strings.TrimPrefix(name, "server_")
|
||||
return fmt.Sprintf("✓ 虚拟机%s%s\n", actionLabel(action), paren(id))
|
||||
|
||||
default:
|
||||
return formatGenericProgress(name, args, resultText)
|
||||
}
|
||||
}
|
||||
|
||||
func isToolResultError(resultText string) bool {
|
||||
s := strings.TrimSpace(resultText)
|
||||
return strings.Contains(s, "调用失败") ||
|
||||
strings.Contains(s, "返回错误") ||
|
||||
strings.HasPrefix(s, "工具 ") && strings.Contains(s, "失败")
|
||||
}
|
||||
|
||||
func progressLabel(toolName string) string {
|
||||
switch {
|
||||
case strings.Contains(toolName, "cloud_region_list"):
|
||||
return "查询区域"
|
||||
case strings.Contains(toolName, "cloud_region_capability"):
|
||||
return "查询区域能力"
|
||||
case strings.Contains(toolName, "cached_image"):
|
||||
return "查询镜像"
|
||||
case strings.Contains(toolName, "image_list"):
|
||||
return "查询镜像"
|
||||
case strings.Contains(toolName, "server_sku"):
|
||||
return "查询套餐"
|
||||
case strings.Contains(toolName, "network_list"):
|
||||
return "查询网络"
|
||||
case strings.Contains(toolName, "vpc_list"):
|
||||
return "查询 VPC"
|
||||
case strings.Contains(toolName, "server_create"):
|
||||
return "创建虚拟机"
|
||||
default:
|
||||
return toolName
|
||||
}
|
||||
}
|
||||
|
||||
func actionLabel(action string) string {
|
||||
switch action {
|
||||
case "start":
|
||||
return "已启动"
|
||||
case "stop":
|
||||
return "已停止"
|
||||
case "restart":
|
||||
return "已重启"
|
||||
case "delete":
|
||||
return "已删除"
|
||||
case "set_password", "set-password":
|
||||
return "已重置密码"
|
||||
default:
|
||||
return "操作完成"
|
||||
}
|
||||
}
|
||||
|
||||
func formatServerCreateProgress(args map[string]interface{}, resultText string, labels *progressLabelCache) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("✓ 选用资源创建虚拟机:")
|
||||
parts := make([]string, 0, 6)
|
||||
if v := firstArg(args, "name", "NAME"); v != "" {
|
||||
if isTruthy(args["generate-name"]) || isTruthy(args["generate_name"]) || isTruthy(args["GenerateName"]) {
|
||||
parts = append(parts, "名称模板="+v+"(自动去重)")
|
||||
} else {
|
||||
parts = append(parts, "名称="+v)
|
||||
}
|
||||
}
|
||||
if v := firstArg(args, "hypervisor"); v != "" {
|
||||
parts = append(parts, "平台="+v)
|
||||
}
|
||||
if v := firstArg(args, "prefer-region", "prefer_region", "region"); v != "" {
|
||||
parts = append(parts, "区域="+labels.regionLabel(v))
|
||||
}
|
||||
if v := firstArg(args, "instance-type", "instance_type", "sku"); v != "" {
|
||||
parts = append(parts, "规格="+v)
|
||||
}
|
||||
if v := firstArg(args, "ncpu"); v != "" {
|
||||
parts = append(parts, "CPU="+v)
|
||||
}
|
||||
if v := firstArg(args, "mem-spec", "mem_spec"); v != "" {
|
||||
parts = append(parts, "内存="+v)
|
||||
}
|
||||
if disks := argStringSlice(args, "disk"); len(disks) > 0 {
|
||||
parts = append(parts, "磁盘="+truncateRunes(disks[0], 80))
|
||||
}
|
||||
if nets := argStringSlice(args, "net"); len(nets) > 0 {
|
||||
parts = append(parts, "网络="+strings.Join(nets, ","))
|
||||
} else {
|
||||
parts = append(parts, "网络=自动调度")
|
||||
}
|
||||
b.WriteString(strings.Join(parts, ","))
|
||||
b.WriteByte('\n')
|
||||
|
||||
body := stripMCPHint(resultText)
|
||||
if obj := parseJSONObject(body); obj != nil {
|
||||
status := jsonString(obj, "final_status")
|
||||
sid := jsonString(obj, "server_id")
|
||||
sname := ""
|
||||
if sid == "" {
|
||||
if srv, ok := obj["server"].(map[string]interface{}); ok {
|
||||
sid = jsonString(srv, "id")
|
||||
sname = jsonString(srv, "name")
|
||||
if status == "" {
|
||||
status = jsonString(srv, "status")
|
||||
}
|
||||
}
|
||||
} else if srv, ok := obj["server"].(map[string]interface{}); ok {
|
||||
sname = jsonString(srv, "name")
|
||||
}
|
||||
if waitErr := jsonString(obj, "wait_error"); waitErr != "" {
|
||||
b.WriteString(fmt.Sprintf(" 创建未完成:%s\n", truncateRunes(waitErr, 160)))
|
||||
if hint := jsonString(obj, "hint"); hint != "" {
|
||||
b.WriteString(fmt.Sprintf(" 提示:%s\n", truncateRunes(hint, 160)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if sid != "" || status != "" || sname != "" {
|
||||
b.WriteString(" 结果:")
|
||||
bits := make([]string, 0, 3)
|
||||
if sname != "" {
|
||||
bits = append(bits, "名称="+sname)
|
||||
}
|
||||
if sid != "" {
|
||||
bits = append(bits, "id="+sid)
|
||||
}
|
||||
if status != "" {
|
||||
bits = append(bits, "状态="+status)
|
||||
}
|
||||
b.WriteString(strings.Join(bits, ","))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isTruthy(v interface{}) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(x))
|
||||
return s == "true" || s == "1" || s == "yes" || s == "on"
|
||||
case float64:
|
||||
return x != 0
|
||||
case int:
|
||||
return x != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func formatResourceListProgress(kind, resultText string, fields []string) string {
|
||||
items, total := extractListItems(resultText)
|
||||
if len(items) == 0 {
|
||||
if total == 0 {
|
||||
return fmt.Sprintf("✓ 未找到可用%s\n", kind)
|
||||
}
|
||||
return fmt.Sprintf("✓ 已查询%s(共 %d 条)\n", kind, total)
|
||||
}
|
||||
if total <= 0 {
|
||||
total = len(items)
|
||||
}
|
||||
labels := make([]string, 0, 5)
|
||||
for i, item := range items {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
labels = append(labels, formatItemLabel(item, fields))
|
||||
}
|
||||
more := ""
|
||||
if total > len(labels) {
|
||||
more = fmt.Sprintf("等共 %d 个", total)
|
||||
} else {
|
||||
more = fmt.Sprintf("共 %d 个", total)
|
||||
}
|
||||
return fmt.Sprintf("✓ 已找到%s:%s(%s)\n", kind, strings.Join(labels, "、"), more)
|
||||
}
|
||||
|
||||
func formatSingleResourceProgress(kind, resultText string, fields []string) string {
|
||||
obj := parseJSONObject(stripMCPHint(resultText))
|
||||
if obj == nil {
|
||||
return fmt.Sprintf("✓ 已获取%s\n", kind)
|
||||
}
|
||||
return fmt.Sprintf("✓ %s:%s\n", kind, formatItemLabel(obj, fields))
|
||||
}
|
||||
|
||||
func formatGenericProgress(toolName string, args map[string]interface{}, resultText string) string {
|
||||
id := firstArg(args, "id", "ID", "name", "NAME")
|
||||
items, total := extractListItems(resultText)
|
||||
if len(items) > 0 {
|
||||
return formatResourceListProgress(progressLabel(toolName), resultText, []string{"name", "id"})
|
||||
}
|
||||
if id != "" {
|
||||
return fmt.Sprintf("✓ %s完成%s\n", progressLabel(toolName), paren(id))
|
||||
}
|
||||
_ = resultText
|
||||
if total > 0 {
|
||||
return fmt.Sprintf("✓ %s完成(%d 条)\n", progressLabel(toolName), total)
|
||||
}
|
||||
return fmt.Sprintf("✓ %s完成\n", progressLabel(toolName))
|
||||
}
|
||||
|
||||
func formatItemLabel(item map[string]interface{}, fields []string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
seen := map[string]bool{}
|
||||
for _, f := range fields {
|
||||
v := jsonString(item, f)
|
||||
if v == "" || seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
parts = append(parts, v)
|
||||
if len(parts) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "(未命名)"
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", parts[0], parts[1])
|
||||
}
|
||||
|
||||
func extractListItems(resultText string) ([]map[string]interface{}, int) {
|
||||
body := stripMCPHint(resultText)
|
||||
obj := parseJSONObject(body)
|
||||
if obj != nil {
|
||||
total := jsonInt(obj, "total")
|
||||
if total <= 0 {
|
||||
total = jsonInt(obj, "count")
|
||||
}
|
||||
for _, key := range []string{"data", "hits"} {
|
||||
data, ok := obj[key].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(data))
|
||||
for _, d := range data {
|
||||
if m, ok := d.(map[string]interface{}); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
total = len(items)
|
||||
}
|
||||
return items, total
|
||||
}
|
||||
// 单对象结果
|
||||
if jsonString(obj, "id") != "" || jsonString(obj, "name") != "" {
|
||||
return []map[string]interface{}{obj}, 1
|
||||
}
|
||||
}
|
||||
if arr := parseJSONArray(body); len(arr) > 0 {
|
||||
items := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, d := range arr {
|
||||
if m, ok := d.(map[string]interface{}); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
return items, len(items)
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func extractStorageTypeHints(resultText string) string {
|
||||
obj := parseJSONObject(stripMCPHint(resultText))
|
||||
if obj == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"storage_types2", "StorageTypes2"} {
|
||||
raw, ok := obj[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
set := make([]string, 0, 8)
|
||||
seen := map[string]bool{}
|
||||
for _, v := range m {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, x := range arr {
|
||||
s, ok := x.(string)
|
||||
if !ok || s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
if i := strings.Index(s, "/"); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
set = append(set, s)
|
||||
if len(set) >= 6 {
|
||||
return strings.Join(set, "、")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
return strings.Join(set, "、")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripMCPHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.Index(s, "[MCP下一步]"); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseJSONObject(s string) map[string]interface{} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s[0] != '{' {
|
||||
// 可能前后有非 JSON 文本,尝试截取第一个对象
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
}
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
func parseJSONArray(s string) []interface{} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if s[0] != '[' {
|
||||
start := strings.Index(s, "[")
|
||||
end := strings.LastIndex(s, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return nil
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func firstArg(args map[string]interface{}, keys ...string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
normalize := func(k string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(k), "_", "-")
|
||||
}
|
||||
for _, key := range keys {
|
||||
if v, ok := args[key]; ok {
|
||||
if s := stringifyArg(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
want := normalize(key)
|
||||
for k, v := range args {
|
||||
if normalize(k) == want {
|
||||
if s := stringifyArg(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func argStringSlice(args map[string]interface{}, key string) []string {
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
alt := strings.ReplaceAll(key, "-", "_")
|
||||
v, ok = args[alt]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case []string:
|
||||
return x
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if s := stringifyArg(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if x != "" {
|
||||
return []string{x}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringifyArg(v interface{}) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(x)
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return fmt.Sprintf("%v", x)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", x)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", x)
|
||||
case bool:
|
||||
return fmt.Sprintf("%v", x)
|
||||
case []interface{}:
|
||||
if len(x) == 0 {
|
||||
return ""
|
||||
}
|
||||
return stringifyArg(x[0])
|
||||
case []string:
|
||||
if len(x) == 0 {
|
||||
return ""
|
||||
}
|
||||
return x[0]
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(x))
|
||||
}
|
||||
}
|
||||
|
||||
func jsonString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stringifyArg(v)
|
||||
}
|
||||
|
||||
func jsonInt(m map[string]interface{}, key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func paren(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "(" + s + ")"
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
rs := []rune(strings.TrimSpace(s))
|
||||
if max <= 0 || len(rs) <= max {
|
||||
return string(rs)
|
||||
}
|
||||
return string(rs[:max]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSummarizeToolProgressRegionList(t *testing.T) {
|
||||
result := `{
|
||||
"total": 2,
|
||||
"data": [
|
||||
{"id": "r1", "name": "北京", "external_id": "cn-beijing"},
|
||||
{"id": "r2", "name": "上海", "external_id": "cn-shanghai"}
|
||||
]
|
||||
}
|
||||
[MCP下一步] ignore`
|
||||
got := summarizeToolProgress("climc_cloud_region_list", nil, result, nil)
|
||||
if !strings.Contains(got, "已找到区域") || !strings.Contains(got, "北京") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "正在调用工具") {
|
||||
t.Fatalf("should not mention tool name style: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeToolProgressCreate(t *testing.T) {
|
||||
args := map[string]interface{}{
|
||||
"name": "ubuntu-22-04",
|
||||
"hypervisor": "aliyun",
|
||||
"prefer-region": "r1",
|
||||
"instance-type": "ecs.t6-c1m1.large",
|
||||
"disk": []interface{}{"size=30g,image=img1,backend=cloud_essd"},
|
||||
}
|
||||
result := `{"server_id":"s1","final_status":"running","server":{"id":"s1","status":"running"}}`
|
||||
got := summarizeToolProgress("climc_server_create", args, result, nil)
|
||||
if !strings.Contains(got, "选用资源创建虚拟机") || !strings.Contains(got, "ubuntu-22-04") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "状态=running") {
|
||||
t.Fatalf("expected final status in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeToolProgressCapability(t *testing.T) {
|
||||
labels := newProgressLabelCache()
|
||||
labels.regions["reg-1"] = "华东1(杭州)"
|
||||
result := `{"storage_types2":{"aliyun":["cloud_essd/ssd","cloud_ssd/ssd"]}}`
|
||||
got := summarizeToolProgress("climc_cloud_region_capability", map[string]interface{}{"id": "reg-1"}, result, labels)
|
||||
if !strings.Contains(got, "cloud_essd") || !strings.Contains(got, "华东1(杭州)") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "reg-1") {
|
||||
t.Fatalf("should show region name not id: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@
|
||||
|
||||
package options
|
||||
|
||||
import common_options "yunion.io/x/onecloud/pkg/cloudcommon/options"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
common_options "yunion.io/x/onecloud/pkg/cloudcommon/options"
|
||||
)
|
||||
|
||||
type LLMOptions struct {
|
||||
common_options.CommonOptions
|
||||
@@ -49,10 +53,11 @@ type LLMOptions struct {
|
||||
|
||||
// MCP Agent 配置
|
||||
MCPServerURL string `help:"MCP Server URL" default:"http://default-mcp-server:30876"`
|
||||
MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"120"`
|
||||
MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"180"`
|
||||
|
||||
MCPAgentUserCharLimit int `help:"MCP Agent user char limit" default:"3200"`
|
||||
MCPAgentAssistantCharLimit int `help:"MCP Agent assistant char limit" default:"6400"`
|
||||
MCPAgentMaxToolRounds int `help:"Max MCP tool-call rounds per chat request" default:"16"`
|
||||
|
||||
// LLM model catalog (browsable curated entries). Value can be either an
|
||||
// http(s) URL or a local file path; sources without an http:// or https://
|
||||
@@ -73,3 +78,28 @@ type LLMOptions struct {
|
||||
var (
|
||||
Options LLMOptions
|
||||
)
|
||||
|
||||
const DefaultPlatformName = "Cloudpods"
|
||||
|
||||
// ResolvedPlatformName 返回配置中的平台展示名,空则回退 DefaultPlatformName。
|
||||
func ResolvedPlatformName() string {
|
||||
name := strings.TrimSpace(Options.PlatformName)
|
||||
if name == "" {
|
||||
return DefaultPlatformName
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func OnOptionsChange(oldO, newO interface{}) bool {
|
||||
oldOpts := oldO.(*LLMOptions)
|
||||
newOpts := newO.(*LLMOptions)
|
||||
|
||||
changed := false
|
||||
if common_options.OnCommonOptionsChange(&oldOpts.CommonOptions, &newOpts.CommonOptions) {
|
||||
changed = true
|
||||
}
|
||||
if common_options.OnDBOptionsChange(&oldOpts.DBOptions, &newOpts.DBOptions) {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func StartService() {
|
||||
app_common.InitAuth(commonOpts, func() {
|
||||
log.Infof("Auth complete!!")
|
||||
})
|
||||
common_options.StartOptionManager(opts, opts.ConfigSyncPeriodSeconds, api.SERVICE_TYPE, api.SERVICE_VERSION, options.OnOptionsChange)
|
||||
|
||||
app := app_common.InitApp(&opts.BaseOptions, false)
|
||||
|
||||
|
||||
+73
-11
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,6 +33,8 @@ import (
|
||||
"yunion.io/x/log"
|
||||
"yunion.io/x/pkg/errors"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/apis/identity"
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
)
|
||||
@@ -60,21 +63,48 @@ type MCPClient struct {
|
||||
messageID int64
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
closed atomic.Bool
|
||||
userCred mcclient.TokenCredential
|
||||
|
||||
// requestTimeout 单次 JSON-RPC(含 tools/call)等待 SSE 回包的上限。
|
||||
// 须覆盖 climc_server_create 的 forecast+等待(ServerCreateWaitSeconds),且小于整段 chat 超时。
|
||||
requestTimeout time.Duration
|
||||
|
||||
pendingReqs map[int64]chan *rawMCPResponse
|
||||
reqMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewMCPClient 创建一个新的 MCP 客户端
|
||||
// NewMCPClient 创建一个新的 MCP 客户端。
|
||||
// timeout 为单次 RPC 等待 SSE 响应的超时;SSE 长连接本身不设整体 Timeout,避免读 body 被提前掐断。
|
||||
func NewMCPClient(serverURL string, timeout time.Duration, userCred mcclient.TokenCredential) *MCPClient {
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(options.Options.MCPAgentTimeout) * time.Second
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Minute
|
||||
}
|
||||
return &MCPClient{
|
||||
serverURL: strings.TrimSuffix(serverURL, "/"),
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: 0,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
},
|
||||
},
|
||||
userCred: userCred,
|
||||
pendingReqs: make(map[int64]chan *rawMCPResponse),
|
||||
requestTimeout: timeout,
|
||||
userCred: userCred,
|
||||
pendingReqs: make(map[int64]chan *rawMCPResponse),
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthHeaders 将当前用户 token 写入请求头,供 mcp-server SSE/message 鉴权。
|
||||
func (c *MCPClient) setAuthHeaders(req *http.Request) {
|
||||
if c.userCred == nil {
|
||||
return
|
||||
}
|
||||
if tok := strings.TrimSpace(c.userCred.GetTokenString()); tok != "" {
|
||||
req.Header.Set(identity.AUTH_TOKEN_HEADER, tok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +118,7 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -95,8 +126,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return errors.Errorf("SSE connection failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
@@ -125,7 +156,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
if err != nil {
|
||||
if !foundSession {
|
||||
initErr = err
|
||||
} else {
|
||||
} else if !isExpectedSSEClose(err) && !c.closed.Load() {
|
||||
// 主动 Close / 对端正常结束时不刷告警
|
||||
log.Warningf("SSE connection closed: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -212,7 +244,7 @@ func (c *MCPClient) Initialize(ctx context.Context) error {
|
||||
ProtocolVersion: "2024-11-05",
|
||||
Capabilities: mcp.ClientCapabilities{},
|
||||
ClientInfo: mcp.Implementation{
|
||||
Name: "cloudpods-mcp-agent",
|
||||
Name: fmt.Sprintf("%s-mcp-agent", options.ResolvedPlatformName()),
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
@@ -318,7 +350,22 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r
|
||||
return &mcpResp, nil
|
||||
}
|
||||
|
||||
// 如果响应为空,等待 SSE 推送
|
||||
// 如果响应为空,等待 SSE 推送(/message 常返回空 body,结果走 SSE)
|
||||
wait := c.requestTimeout
|
||||
if wait <= 0 {
|
||||
wait = 3 * time.Minute
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
remain := time.Until(deadline)
|
||||
if remain <= 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if remain < wait {
|
||||
wait = remain
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case mcpResp := <-respChan:
|
||||
log.Debugf("MCP response (SSE): ID=%v", mcpResp.ID)
|
||||
@@ -328,8 +375,8 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r
|
||||
return mcpResp, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(30 * time.Second):
|
||||
return nil, errors.Error("timeout waiting for SSE response")
|
||||
case <-timer.C:
|
||||
return nil, errors.Errorf("timeout waiting for SSE response after %s", wait)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,14 +470,29 @@ func FormatToolResult(toolName string, result *mcp.CallToolResult, err error) st
|
||||
return GetToolResultText(result)
|
||||
}
|
||||
|
||||
// isExpectedSSEClose 判断是否为正常关闭(主动 Close / EOF / 连接已关)
|
||||
func isExpectedSSEClose(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Cause(err) == io.EOF || errors.Cause(err) == net.ErrClosed {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "use of closed network connection") ||
|
||||
strings.Contains(msg, "closed network connection") ||
|
||||
strings.Contains(msg, "http: read on closed response body")
|
||||
}
|
||||
|
||||
// Close 关闭客户端连接
|
||||
func (c *MCPClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed.Store(true)
|
||||
c.initialized = false
|
||||
c.sessionURL = ""
|
||||
if c.sseBody != nil {
|
||||
c.sseBody.Close()
|
||||
_ = c.sseBody.Close()
|
||||
c.sseBody = nil
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user