diff --git a/cmd/climc/shell/llm/mcp_agent.go b/cmd/climc/shell/llm/mcp_agent.go index 6b100afb62..4a0c08c215 100644 --- a/cmd/climc/shell/llm/mcp_agent.go +++ b/cmd/climc/shell/llm/mcp_agent.go @@ -28,6 +28,7 @@ func init() { // cmd.Get("chat-test", new(options.MCPAgentChatTestOptions)) cmd.Get("request", new(options.MCPAgentMCPAgentRequestOptions)) shell.R(&options.MCPAgentMCPAgentRequestOptions{}, "mcp-agent-chat", "Chat with MCP Agent (Stream)", chatStream) + shell.R(&options.MCPAgentDefaultChatOptions{}, "mcp-agent-chat-default", "Chat with default MCP Agent (Stream, no ID required)", chatStreamDefault) } func chatStream(s *mcclient.ClientSession, args *options.MCPAgentMCPAgentRequestOptions) error { @@ -88,3 +89,50 @@ func chatStream(s *mcclient.ClientSession, args *options.MCPAgentMCPAgentRequest fmt.Println() return nil } + +func chatStreamDefault(s *mcclient.ClientSession, args *options.MCPAgentDefaultChatOptions) error { + bodyJSON, err := args.Params() + if err != nil { + return fmt.Errorf("failed to build request params: %v", err) + } + headers := http.Header{} + headers.Set("Content-Type", "application/json") + body := strings.NewReader(bodyJSON.String()) + path := "/mcp_agents/default/chat-stream" + resp, err := s.RawVersionRequest( + modules.MCPAgent.ServiceType(), + modules.MCPAgent.EndpointType(), + "POST", + path, + headers, + body, + ) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Error: %s %s", resp.Status, string(respBody)) + } + scanner := bufio.NewScanner(resp.Body) + var eventData []string + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if len(eventData) > 0 { + fmt.Print(strings.Join(eventData, "\n")) + eventData = nil + } + continue + } + if after, found := strings.CutPrefix(line, "data: "); found { + eventData = append(eventData, after) + } + } + if err := scanner.Err(); err != nil { + return err + } + fmt.Println() + return nil +} diff --git a/pkg/apigateway/handler/mcp_agent.go b/pkg/apigateway/handler/mcp_agent.go index dada2d9a1f..6de9a19e95 100644 --- a/pkg/apigateway/handler/mcp_agent.go +++ b/pkg/apigateway/handler/mcp_agent.go @@ -149,3 +149,106 @@ func mcpAgentChatStreamHandler(ctx context.Context, w http.ResponseWriter, r *ht } } } + +// mcpAgentDefaultChatStreamHandler 将请求转发到 region 的 default-chat-stream(使用 default_agent=true 的条目) +func mcpAgentDefaultChatStreamHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { + token := AppContextToken(ctx) + s := auth.GetSession(ctx, token, FetchRegion(r)) + + headers := http.Header{} + headers.Set("Content-Type", "application/json") + + var bodyReader io.Reader + if r.Body != nil { + bodyReader = r.Body + } + + path := "/mcp_agents/default-chat-stream" + resp, err := s.RawVersionRequest( + modules.MCPAgent.ServiceType(), + modules.MCPAgent.EndpointType(), + "POST", + path, + headers, + bodyReader, + ) + if err != nil { + httperrors.GeneralServerError(ctx, w, errors.Wrap(err, "request backend")) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + httperrors.InputParameterError(ctx, w, "backend error: %s", string(respBody)) + } else { + httperrors.GeneralServerError(ctx, w, fmt.Errorf("backend error %d: %s", resp.StatusCode, string(respBody))) + } + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + buf := make([]byte, 1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, wErr := w.Write(buf[:n]); wErr != nil { + log.Errorf("write response error: %v", wErr) + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + if err != nil { + if err != io.EOF { + log.Errorf("read backend response error: %v", err) + } + break + } + } +} + +// mcpAgentDefaultToolsHandler 将 GET 请求转发到 region 的 default-mcp-tools(仅使用 options.MCPServerURL,不通过 mcp_agent 条目) +func mcpAgentDefaultToolsHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { + token := AppContextToken(ctx) + s := auth.GetSession(ctx, token, FetchRegion(r)) + + path := "/mcp_agents/default-mcp-tools" + resp, err := s.RawVersionRequest( + modules.MCPAgent.ServiceType(), + modules.MCPAgent.EndpointType(), + "GET", + path, + nil, + nil, + ) + if err != nil { + httperrors.GeneralServerError(ctx, w, errors.Wrap(err, "request backend")) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + httperrors.InputParameterError(ctx, w, "backend error: %s", string(respBody)) + } else { + httperrors.GeneralServerError(ctx, w, fmt.Errorf("backend error %d: %s", resp.StatusCode, string(respBody))) + } + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = io.Copy(w, resp.Body) + if err != nil { + log.Errorf("write default mcp tools response error: %v", err) + } +} diff --git a/pkg/apigateway/handler/misc.go b/pkg/apigateway/handler/misc.go index 8b82eef5da..9d73b800fc 100644 --- a/pkg/apigateway/handler/misc.go +++ b/pkg/apigateway/handler/misc.go @@ -108,6 +108,11 @@ func (h *MiscHandler) Bind(app *appsrv.Application) { // mcp agent chat stream chatStream := chatHandlerInfo("POST", prefix+"mcp_agents//chat-stream", FetchAuthToken(mcpAgentChatStreamHandler)) app.AddHandler3(chatStream) + // mcp agent default chat stream (uses agent with default_agent=true) + defaultChatStream := chatHandlerInfo("POST", prefix+"mcp_agents/default/chat-stream", FetchAuthToken(mcpAgentDefaultChatStreamHandler)) + app.AddHandler3(defaultChatStream) + // mcp agent default MCP server tools (options.MCPServerURL only, no mcp_agent entry) + app.AddHandler(GET, prefix+"mcp_agents/default-mcp-tools", FetchAuthToken(mcpAgentDefaultToolsHandler)) // syslog webservice handlers app.AddHandler(POST, prefix+"syslog/token", handleSyslogWebServiceToken) diff --git a/pkg/apis/llm/mcp_agent.go b/pkg/apis/llm/mcp_agent.go index ae32700b1a..9280a65daf 100644 --- a/pkg/apis/llm/mcp_agent.go +++ b/pkg/apis/llm/mcp_agent.go @@ -60,36 +60,40 @@ func IsLLMClientType(t string) bool { type MCPAgentListInput struct { apis.SharableVirtualResourceListInput - LLMDriver string `json:"llm_driver"` + LLMDriver string `json:"llm_driver"` + DefaultAgent *bool `json:"default_agent,omitempty" help:"filter by default agent (true to get the default one)"` } type MCPAgentCreateInput struct { apis.SharableVirtualResourceCreateInput - LLMId string `json:"llm_id" help:"LLM 实例 ID,如果提供则自动获取 llm_url"` - LLMUrl string `json:"llm_url" help:"后端大模型的 base 请求地址"` - LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,可以是 ollama 或 openai"` - Model string `json:"model" help:"使用的模型名称"` - ApiKey string `json:"api_key" help:"在 llm_driver 中需要用到的认证"` - McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"` + LLMId string `json:"llm_id" help:"LLM 实例 ID,如果提供则自动获取 llm_url"` + LLMUrl string `json:"llm_url" help:"后端大模型的 base 请求地址"` + LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,可以是 ollama 或 openai"` + Model string `json:"model" help:"使用的模型名称"` + ApiKey string `json:"api_key" help:"在 llm_driver 中需要用到的认证"` + McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"` + DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"` } type MCPAgentUpdateInput struct { apis.SharableVirtualResourceCreateInput - LLMId *string `json:"llm_id,omitempty" help:"LLM 实例 ID,如果提供则自动获取 llm_url"` - LLMUrl *string `json:"llm_url,omitempty" help:"后端大模型的 base 请求地址"` - LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,可以是 ollama 或 openai"` - Model *string `json:"model,omitempty" help:"使用的模型名称"` - ApiKey *string `json:"api_key,omitempty" help:"在 llm_driver 中需要用到的认证"` - McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"` + LLMId *string `json:"llm_id,omitempty" help:"LLM 实例 ID,如果提供则自动获取 llm_url"` + LLMUrl *string `json:"llm_url,omitempty" help:"后端大模型的 base 请求地址"` + LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,可以是 ollama 或 openai"` + Model *string `json:"model,omitempty" help:"使用的模型名称"` + ApiKey *string `json:"api_key,omitempty" help:"在 llm_driver 中需要用到的认证"` + McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"` + DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"` } type MCPAgentDetails struct { apis.SharableVirtualResourceDetails - LLMId string `json:"llm_id"` - LLMName string `json:"llm_name"` + LLMId string `json:"llm_id"` + LLMName string `json:"llm_name"` + DefaultAgent bool `json:"default_agent"` } type LLMToolRequestInput struct { diff --git a/pkg/llm/drivers/llm_client/ollama.go b/pkg/llm/drivers/llm_client/ollama.go index e749cd326d..49ecab2f9b 100644 --- a/pkg/llm/drivers/llm_client/ollama.go +++ b/pkg/llm/drivers/llm_client/ollama.go @@ -252,6 +252,29 @@ func (o *ollama) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCal } } +func (o *ollama) NewAssistantMessageWithToolCallsAndReasoning(reasoningContent, content string, toolCalls []models.ILLMToolCall) models.ILLMChatMessage { + ollamaToolCalls := make([]OllamaToolCall, len(toolCalls)) + for i, tc := range toolCalls { + if otc, ok := tc.(*OllamaToolCall); ok { + ollamaToolCalls[i] = *otc + } else { + fc := tc.GetFunction() + ollamaToolCalls[i] = OllamaToolCall{ + Function: OllamaFunctionCall{ + Name: fc.GetName(), + Arguments: fc.GetArguments(), + }, + } + } + } + _ = reasoningContent // Ollama does not use reasoning_content; ignore for compatibility + return &OllamaChatMessage{ + Role: "assistant", + Content: content, + ToolCalls: ollamaToolCalls, + } +} + func (o *ollama) NewToolMessage(toolId string, toolName string, content string) models.ILLMChatMessage { return &OllamaChatMessage{ Role: "tool", @@ -323,6 +346,7 @@ func (m OllamaChatMessage) GetToolCalls() []models.ILLMToolCall { // OllamaToolCall 表示工具调用 // 实现 ILLMToolCall 接口 type OllamaToolCall struct { + Index int `json:"-"` Function OllamaFunctionCall `json:"function"` } @@ -331,6 +355,11 @@ func (tc *OllamaToolCall) GetFunction() models.ILLMFunctionCall { return &tc.Function } +// GetIndex 实现 ILLMToolCall 接口 +func (tc *OllamaToolCall) GetIndex() int { + return tc.Index +} + // GetId 实现 ILLMToolCall 接口 func (tc *OllamaToolCall) GetId() string { return "" @@ -348,6 +377,15 @@ func (fc *OllamaFunctionCall) GetName() string { return fc.Name } +// GetRawArguments 实现 ILLMFunctionCall 接口 +func (fc *OllamaFunctionCall) GetRawArguments() string { + if fc.Arguments == nil { + return "" + } + bytes, _ := json.Marshal(fc.Arguments) + return string(bytes) +} + // GetArguments 实现 ILLMFunctionCall 接口 func (fc *OllamaFunctionCall) GetArguments() map[string]interface{} { return fc.Arguments @@ -415,6 +453,11 @@ func (r *OllamaChatResponse) GetContent() string { return r.Message.Content } +// GetReasoningContent 获取推理内容(Ollama 不支持,返回空) +func (r *OllamaChatResponse) GetReasoningContent() string { + return "" +} + // HasToolCalls 检查响应是否包含工具调用 func (r *OllamaChatResponse) HasToolCalls() bool { return len(r.Message.ToolCalls) > 0 @@ -427,6 +470,7 @@ func (r *OllamaChatResponse) GetToolCalls() []models.ILLMToolCall { } toolCalls := make([]models.ILLMToolCall, len(r.Message.ToolCalls)) for i := range r.Message.ToolCalls { + r.Message.ToolCalls[i].Index = i toolCalls[i] = &r.Message.ToolCalls[i] } return toolCalls diff --git a/pkg/llm/drivers/llm_client/openai.go b/pkg/llm/drivers/llm_client/openai.go index cdb3d824b6..adf59e1673 100644 --- a/pkg/llm/drivers/llm_client/openai.go +++ b/pkg/llm/drivers/llm_client/openai.go @@ -40,7 +40,7 @@ func (o *openai) Chat(ctx context.Context, mcpAgent *models.SMCPAgent, messages } else if msgs, ok := messages.([]models.ILLMChatMessage); ok { openaiMessages = make([]OpenAIChatMessage, len(msgs)) for i, msg := range msgs { - // Check if it's an OpenAIChatMessage to preserve ToolCallID + // Check if it's an OpenAIChatMessage to preserve ToolCallID and ReasoningContent if om, ok := msg.(*OpenAIChatMessage); ok { openaiMessages[i] = *om } else { @@ -113,7 +113,7 @@ func (o *openai) ChatStream(ctx context.Context, mcpAgent *models.SMCPAgent, mes openaiMessages = make([]OpenAIChatMessage, len(ilMsgs)) for i, msg := range ilMsgs { - // Check if it's an OpenAIChatMessage to preserve ToolCallID + // Check if it's an OpenAIChatMessage to preserve ToolCallID and ReasoningContent if om, ok := msg.(*OpenAIChatMessage); ok { openaiMessages[i] = *om } else { @@ -359,6 +359,32 @@ func (o *openai) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCal } } +func (o *openai) NewAssistantMessageWithToolCallsAndReasoning(reasoningContent, content string, toolCalls []models.ILLMToolCall) models.ILLMChatMessage { + openaiToolCalls := make([]OpenAIToolCall, len(toolCalls)) + for i, tc := range toolCalls { + if otc, ok := tc.(*OpenAIToolCall); ok { + openaiToolCalls[i] = *otc + } else { + fc := tc.GetFunction() + argsBytes, _ := json.Marshal(fc.GetArguments()) + openaiToolCalls[i] = OpenAIToolCall{ + ID: tc.GetId(), + Type: "function", + Function: OpenAIFunctionCall{ + Name: fc.GetName(), + Arguments: string(argsBytes), + }, + } + } + } + return &OpenAIChatMessage{ + Role: "assistant", + Content: content, + ReasoningContent: reasoningContent, + ToolCalls: openaiToolCalls, + } +} + func (o *openai) NewToolMessage(toolId string, toolName string, content string) models.ILLMChatMessage { return &OpenAIChatMessage{ Role: "tool", @@ -399,10 +425,11 @@ func (o *openai) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool { // Structures type OpenAIChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } func (m *OpenAIChatMessage) GetRole() string { return m.Role } @@ -420,6 +447,7 @@ func (m *OpenAIChatMessage) GetToolCalls() []models.ILLMToolCall { } type OpenAIToolCall struct { + Index int `json:"index"` ID string `json:"id"` Type string `json:"type"` Function OpenAIFunctionCall `json:"function"` @@ -427,13 +455,15 @@ type OpenAIToolCall struct { func (tc *OpenAIToolCall) GetFunction() models.ILLMFunctionCall { return &tc.Function } func (tc *OpenAIToolCall) GetId() string { return tc.ID } +func (tc *OpenAIToolCall) GetIndex() int { return tc.Index } type OpenAIFunctionCall struct { Name string `json:"name"` Arguments string `json:"arguments"` } -func (fc *OpenAIFunctionCall) GetName() string { return fc.Name } +func (fc *OpenAIFunctionCall) GetName() string { return fc.Name } +func (fc *OpenAIFunctionCall) GetRawArguments() string { return fc.Arguments } func (fc *OpenAIFunctionCall) GetArguments() map[string]interface{} { var args map[string]interface{} _ = json.Unmarshal([]byte(fc.Arguments), &args) @@ -482,6 +512,13 @@ func (r *OpenAIChatResponse) GetContent() string { return "" } +func (r *OpenAIChatResponse) GetReasoningContent() string { + if len(r.Choices) > 0 { + return r.Choices[0].Message.ReasoningContent + } + return "" +} + func (r *OpenAIChatResponse) HasToolCalls() bool { return len(r.Choices) > 0 && len(r.Choices[0].Message.ToolCalls) > 0 } @@ -504,9 +541,10 @@ type OpenAIChatStreamChoice struct { } type OpenAIChatStreamDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` } func (r *OpenAIChatStreamResponse) GetContent() string { @@ -516,6 +554,13 @@ func (r *OpenAIChatStreamResponse) GetContent() string { return "" } +func (r *OpenAIChatStreamResponse) GetReasoningContent() string { + if len(r.Choices) > 0 { + return r.Choices[0].Delta.ReasoningContent + } + return "" +} + func (r *OpenAIChatStreamResponse) HasToolCalls() bool { return len(r.Choices) > 0 && len(r.Choices[0].Delta.ToolCalls) > 0 } diff --git a/pkg/llm/models/instantmodel.go b/pkg/llm/models/instantmodel.go index 558a250a57..33ae6574b8 100644 --- a/pkg/llm/models/instantmodel.go +++ b/pkg/llm/models/instantmodel.go @@ -667,15 +667,31 @@ func (model *SInstantModel) PerformPrivate( func (model *SInstantModel) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSONObject) error { if model.Enabled.IsTrue() { - for _, man := range []MountedModelModelManager{GetLLMSkuManager(), GetVolumeManager()} { - // volume/sku 存储格式为 modelFullName-instantModelId - used, err := man.IsPremountedModelName(model.ModelName + ":" + model.ModelTag + "-" + model.Id) - if err != nil { - return errors.Wrap(err, "IsPremountedModelName") - } - if used { - return errors.Wrap(errors.ErrInvalidStatus, "cannot delete when model is used by other resources") - } + // check if used by llm sku + used, err := GetLLMSkuManager().IsPremountedModelName(model.Id) + if err != nil { + return errors.Wrap(err, "GetLLMSkuManager().IsPremountedModelName") + } + if used { + return errors.Wrap(errors.ErrInvalidStatus, "cannot delete when model is used by llm sku") + } + + // check if used by volume + used, err = GetVolumeManager().IsPremountedModelName(model.Id) + if err != nil { + return errors.Wrap(err, "GetVolumeManager().IsPremountedModelName") + } + if used { + return errors.Wrap(errors.ErrInvalidStatus, "cannot delete when model is used by volume") + } + + // check if used by llm instance + cnt, err := GetLLMInstantModelManager().Query().Equals("model_id", model.Id).IsFalse("deleted").CountWithError() + if err != nil { + return errors.Wrap(err, "GetLLMInstantModelManager().CountWithError") + } + if cnt > 0 { + return errors.Wrap(errors.ErrInvalidStatus, "cannot delete when model is used by llm instance") } } diff --git a/pkg/llm/models/llm.go b/pkg/llm/models/llm.go index c17c387851..408aac9895 100644 --- a/pkg/llm/models/llm.go +++ b/pkg/llm/models/llm.go @@ -499,16 +499,20 @@ func (llm *SLLM) StartLLMStopTask(ctx context.Context, userCred mcclient.TokenCr return nil } -// func (llm *SLLM) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSONObject) error { -// instanceId, isBound, err := llm.IsBoundToInstance() -// if err != nil { -// return errors.Wrap(err, "IsBoundToInstance") -// } -// if isBound { -// return httperrors.NewBadRequestError("llm is bound to instance %s", instanceId) -// } -// return nil -// } +func (llm *SLLM) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSONObject) error { + if err := llm.SLLMBase.ValidateDeleteCondition(ctx, info); err != nil { + return err + } + // Check for associated MCPAgents + cnt, err := GetMCPAgentManager().Query().Equals("llm_id", llm.Id).CountWithError() + if err != nil { + return errors.Wrap(err, "GetMCPAgentManager().Query().CountWithError") + } + if cnt > 0 { + return httperrors.NewConflictError("LLM is being used by %d MCPAgents", cnt) + } + return nil +} func (llm *SLLM) WaitContainerStatus(ctx context.Context, userCred mcclient.TokenCredential, targetStatus []string, timeoutSecs int) (*computeapi.SContainer, error) { llmCtr, err := llm.GetLLMContainer() diff --git a/pkg/llm/models/llm_client_driver.go b/pkg/llm/models/llm_client_driver.go index 26f6727630..707e224de8 100644 --- a/pkg/llm/models/llm_client_driver.go +++ b/pkg/llm/models/llm_client_driver.go @@ -18,12 +18,14 @@ type ILLMChatMessage interface { type ILLMToolCall interface { GetId() string GetFunction() ILLMFunctionCall + GetIndex() int } // ILLMFunctionCall 表示函数调用详情接口 type ILLMFunctionCall interface { GetName() string GetArguments() map[string]interface{} + GetRawArguments() string } // ILLMTool 表示工具定义接口 @@ -48,6 +50,8 @@ type ILLMChatResponse interface { GetToolCalls() []ILLMToolCall // GetContent 获取响应内容 GetContent() string + // GetReasoningContent 获取推理/思考内容(如 DeepSeek reasoning_content) + GetReasoningContent() string } type ILLMClient interface { @@ -59,6 +63,7 @@ type ILLMClient interface { NewUserMessage(content string) ILLMChatMessage NewAssistantMessage(content string) ILLMChatMessage NewAssistantMessageWithToolCalls(toolCalls []ILLMToolCall) ILLMChatMessage + NewAssistantMessageWithToolCallsAndReasoning(reasoningContent, content string, toolCalls []ILLMToolCall) ILLMChatMessage NewToolMessage(toolId string, toolName string, content string) ILLMChatMessage NewSystemMessage(content string) ILLMChatMessage @@ -74,6 +79,10 @@ func (tc *SLLMToolCall) GetId() string { return tc.Id } +func (tc *SLLMToolCall) GetIndex() int { + return 0 +} + func (tc *SLLMToolCall) GetFunction() ILLMFunctionCall { return &tc.Function } @@ -87,6 +96,10 @@ func (fc *SLLMFunctionCall) GetName() string { return fc.Name } +func (fc *SLLMFunctionCall) GetRawArguments() string { + return "" +} + func (fc *SLLMFunctionCall) GetArguments() map[string]interface{} { return fc.Arguments } diff --git a/pkg/llm/models/llm_instant_model.go b/pkg/llm/models/llm_instant_model.go index 34f793cc34..cb4b7c0ea7 100644 --- a/pkg/llm/models/llm_instant_model.go +++ b/pkg/llm/models/llm_instant_model.go @@ -237,3 +237,21 @@ func (mdl *SLLMInstantModel) getMountPathsFromImage(isInstall bool) ([]api.LLMMo }) return info, nil } + +func (man *SLLMInstantModelManager) DeleteByLlmId(ctx context.Context, llmId string) error { + q := man.Query().Equals("llm_id", llmId) + models := make([]SLLMInstantModel, 0) + err := db.FetchModelObjects(man, q, &models) + if err != nil { + return errors.Wrap(err, "FetchModelObjects") + } + for i := range models { + _, err := db.Update(&models[i], func() error { + return models[i].MarkDelete() + }) + if err != nil { + return errors.Wrapf(err, "delete llm model %s", models[i].ModelName) + } + } + return nil +} diff --git a/pkg/llm/models/llm_instant_model_sync.go b/pkg/llm/models/llm_instant_model_sync.go index ff12a5314a..be8c5a4b02 100644 --- a/pkg/llm/models/llm_instant_model_sync.go +++ b/pkg/llm/models/llm_instant_model_sync.go @@ -3,7 +3,6 @@ package models import ( "context" "database/sql" - "fmt" "net/http" "slices" "strings" @@ -347,7 +346,7 @@ func (llm *SLLM) FetchModelsFullName(isProbed, isMounted *bool) ([]string, error } mdlFullNames := make([]string, len(models)) for idx, mdl := range models { - mdlFullNames[idx] = mdl.ModelName + ":" + mdl.Tag + "-" + mdl.InstantModelId + mdlFullNames[idx] = mdl.InstantModelId } return mdlFullNames, nil } @@ -690,7 +689,7 @@ func (llm *SLLM) UpdateMountedModelFullNames(ctx context.Context, userCred mccli installModelFullNames := make([]string, 0) for _, mdlFullNameInfo := range mdlFullNameInfos { - installModelFullNames = append(installModelFullNames, fmt.Sprintf("%s-%s", mdlFullNameInfo.ModelFullName, mdlFullNameInfo.InstantModelId)) + installModelFullNames = append(installModelFullNames, mdlFullNameInfo.InstantModelId) if !mdlFullNameInfo.IsMounted { modelName, modelTag, _ := llm.GetLargeLanguageModelName(mdlFullNameInfo.ModelFullName) _, err := GetLLMInstantModelManager().updateInstantModel(ctx, llm.Id, mdlFullNameInfo.InstantModelId, modelName, modelTag, &boolFalse, &boolTrue) diff --git a/pkg/llm/models/mcp_agent.go b/pkg/llm/models/mcp_agent.go index a5ee18a768..24faca4593 100644 --- a/pkg/llm/models/mcp_agent.go +++ b/pkg/llm/models/mcp_agent.go @@ -2,6 +2,8 @@ package models import ( "context" + "database/sql" + "encoding/json" "fmt" "net/http" "strings" @@ -16,6 +18,7 @@ import ( api "yunion.io/x/onecloud/pkg/apis/llm" "yunion.io/x/onecloud/pkg/appsrv" "yunion.io/x/onecloud/pkg/cloudcommon/db" + "yunion.io/x/onecloud/pkg/cloudcommon/policy" "yunion.io/x/onecloud/pkg/httperrors" "yunion.io/x/onecloud/pkg/llm/options" "yunion.io/x/onecloud/pkg/llm/utils" @@ -31,6 +34,10 @@ var mcpAgentManager *SMCPAgentManager var mcpAgentWorkerMan *appsrv.SWorkerManager +func GetMCPAgentWorkerManager() *appsrv.SWorkerManager { + return mcpAgentWorkerMan +} + func GetMCPAgentManager() *SMCPAgentManager { if mcpAgentManager != nil { return mcpAgentManager @@ -51,6 +58,64 @@ type SMCPAgentManager struct { db.SSharableVirtualResourceBaseManager } +// unsetOtherDefaultAgents 将除 excludeId 外所有条目的 default_agent 置为 false,保证全局唯一 +func (man *SMCPAgentManager) unsetOtherDefaultAgents(ctx context.Context, excludeId string) error { + q := man.Query().IsTrue("default_agent") + if len(excludeId) > 0 { + q = q.NotEquals("id", excludeId) + } + agents := make([]SMCPAgent, 0) + err := db.FetchModelObjects(man, q, &agents) + if err != nil { + return errors.Wrap(err, "FetchModelObjects") + } + for i := range agents { + _, err := db.Update(&agents[i], func() error { + agents[i].DefaultAgent = false + return nil + }) + if err != nil { + return errors.Wrapf(err, "Update agent %s", agents[i].Id) + } + } + return nil +} + +// GetDefaultAgent 返回当前用户可见的、default_agent=true 的那条 MCP Agent(仅一条) +func (man *SMCPAgentManager) GetDefaultAgent(ctx context.Context, userCred mcclient.TokenCredential) (*SMCPAgent, error) { + query := jsonutils.NewDict() + query.Set("default_agent", jsonutils.JSONTrue) + ownerId, scope, err, _ := db.FetchCheckQueryOwnerScope(ctx, userCred, query, man, policy.PolicyActionList, true) + if err != nil { + return nil, errors.Wrap(err, "FetchCheckQueryOwnerScope") + } + q := man.Query() + q = man.FilterByOwner(ctx, q, man, userCred, ownerId, scope) + q = q.IsTrue("default_agent") + var agent SMCPAgent + err = q.First(&agent) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, nil + } + return nil, errors.Wrap(err, "First default agent") + } + return &agent, nil +} + +// GetDefaultMcpServerTools 返回默认 MCP 服务器(options.Options.MCPServerURL)的 tools,不依赖任何 mcp_agent 记录 +func (man *SMCPAgentManager) GetDefaultMcpServerTools(ctx context.Context, userCred mcclient.TokenCredential) (jsonutils.JSONObject, error) { + timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second + mcpClient := utils.NewMCPClient(options.Options.MCPServerURL, timeout, userCred) + defer mcpClient.Close() + + tools, err := mcpClient.ListTools(ctx) + if err != nil { + return nil, errors.Wrap(err, "list default MCP tools") + } + return jsonutils.Marshal(tools), nil +} + type SMCPAgent struct { db.SSharableVirtualResourceBase @@ -67,6 +132,8 @@ type SMCPAgent struct { ApiKey string `width:"512" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"` // McpServer 即 mcp 服务器的后端地址 McpServer string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"` + // DefaultAgent 是否为默认 Agent,全局仅允许一条为 true + DefaultAgent bool `default:"false" list:"user" create:"optional" update:"user"` } func (mcp *SMCPAgent) BeforeInsert() { @@ -99,6 +166,24 @@ func (mcp *SMCPAgent) BeforeUpdate() { } } +func (mcp *SMCPAgent) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) { + mcp.SSharableVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data) + if mcp.DefaultAgent { + if err := GetMCPAgentManager().unsetOtherDefaultAgents(ctx, mcp.Id); err != nil { + log.Errorf("unsetOtherDefaultAgents after create: %v", err) + } + } +} + +func (mcp *SMCPAgent) PostUpdate(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) { + mcp.SSharableVirtualResourceBase.PostUpdate(ctx, userCred, query, data) + if mcp.DefaultAgent { + if err := GetMCPAgentManager().unsetOtherDefaultAgents(ctx, mcp.Id); err != nil { + log.Errorf("unsetOtherDefaultAgents after update: %v", err) + } + } +} + func (mcp *SMCPAgent) GetApiKey() (string, error) { if len(mcp.ApiKey) == 0 { return "", nil @@ -253,6 +338,9 @@ func (man *SMCPAgentManager) ListItemFilter( if len(input.LLMDriver) > 0 { q = q.Equals("llm_driver", strings.ToLower(strings.TrimSpace(input.LLMDriver))) } + if input.DefaultAgent != nil && *input.DefaultAgent { + q = q.IsTrue("default_agent") + } return q, nil } @@ -293,6 +381,7 @@ func (manager *SMCPAgentManager) FetchCustomizeColumns( if name, ok := llmIdNameMap[agents[i].LLMId]; ok { rows[i].LLMName = name } + rows[i].DefaultAgent = agents[i].DefaultAgent } } @@ -303,10 +392,21 @@ func (mcp *SMCPAgent) GetLLMClientDriver() ILLMClient { return GetLLMClientDriver(api.LLMClientType(mcp.LLMDriver)) } +func (mcp *SMCPAgent) GetMcpServerUrl(ctx context.Context, userCred mcclient.TokenCredential) (string, error) { + if len(mcp.McpServer) > 0 { + return mcp.McpServer, nil + } + return options.Options.MCPServerURL, nil +} + func (mcp *SMCPAgent) GetDetailsMcpTools(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (jsonutils.JSONObject, error) { // 创建 MCP 客户端 timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second - mcpClient := utils.NewMCPClient(options.Options.MCPServerURL, timeout, userCred) + mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred) + if err != nil { + return nil, errors.Wrap(err, "GetMcpServerUrl") + } + mcpClient := utils.NewMCPClient(mcpServerUrl, timeout, userCred) // 获取工具列表 tools, err := mcpClient.ListTools(ctx) @@ -324,7 +424,11 @@ func (mcp *SMCPAgent) GetDetailsToolRequest( ) (jsonutils.JSONObject, error) { // 创建 MCP 客户端 timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second - mcpClient := utils.NewMCPClient(options.Options.MCPServerURL, timeout, userCred) + mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred) + if err != nil { + return nil, errors.Wrap(err, "GetMcpServerUrl") + } + mcpClient := utils.NewMCPClient(mcpServerUrl, timeout, userCred) defer mcpClient.Close() // 调用工具 @@ -399,12 +503,13 @@ func (mcp *SMCPAgent) PerformChatStream( } // process 处理用户请求 -// 强制分为两个阶段: -// 阶段一:使用 Chat 非流式获取工具调用参数,并执行工具 -// 阶段二:使用 ChatStream 流式获取最终响应 func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) { // 获取 MCP Server 的工具列表 - mcpClient := utils.NewMCPClient(mcp.McpServer, 10*time.Minute, userCred) + mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred) + if err != nil { + return nil, errors.Wrap(err, "GetMcpServerUrl") + } + mcpClient := utils.NewMCPClient(mcpServerUrl, 10*time.Minute, userCred) defer mcpClient.Close() mcpTools, err := mcpClient.ListTools(ctx) if err != nil { @@ -444,43 +549,107 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden var toolCallRecords []api.MCPAgentToolCallRecord log.Infof("Phase 1: Thinking & Acting...") - resp, err := llmClient.Chat(ctx, mcp, messages, tools) + + // 处理流式的工具调用参数 + 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 + + 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) + } + } + } + + 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 + } + } + } + return nil + }) + if err != nil { - return nil, errors.Wrap(err, "phase 1 chat error") + return nil, errors.Wrap(err, "phase 1 chat stream error") } // 检查是否有工具调用 - if !resp.HasToolCalls() { - // 如果阶段一没有调用工具,模拟推流返回结果 - content := resp.GetContent() - if onStream != nil && len(content) > 0 { - // 模拟流式输出:按字符逐块推送 - chunkSize := 10 // 每次推送10个字符 - for i := 0; i < len(content); i += chunkSize { - end := i + chunkSize - if end > len(content) { - end = len(content) - } - chunk := content[i:end] - if err := onStream(chunk); err != nil { - return nil, errors.Wrap(err, "stream content error") - } - // 添加小延迟模拟真实流式输出 - time.Sleep(10 * time.Millisecond) - } - } + if !hasToolCalls { + // 如果阶段一没有调用工具,直接返回结果 return &api.MCPAgentResponse{ Success: true, - Answer: content, + Answer: accumulatedContent.String(), ToolCalls: toolCallRecords, }, nil } - // 处理工具调用 - toolCalls := resp.GetToolCalls() + // Convert accumulated tool calls to ILLMToolCall + var toolCalls []ILLMToolCall + // Find max index + 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{} + 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{ + Name: atc.Name, + Arguments: args, + }, + }) + } + } log.Infof("Got %d tool calls from Phase 1", len(toolCalls)) - toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, mcpClient, llmClient) + toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient) if err != nil { return nil, errors.Wrap(err, "process tool calls") } @@ -572,13 +741,15 @@ func processHistoryMessages( func processToolCalls( ctx context.Context, toolCalls []ILLMToolCall, + reasoningContent, content string, mcpClient *utils.MCPClient, llmClient ILLMClient, ) ([]api.MCPAgentToolCallRecord, []ILLMChatMessage, error) { toolCallRecords := make([]api.MCPAgentToolCallRecord, 0) messagesToAdd := make([]ILLMChatMessage, 0) - messagesToAdd = append(messagesToAdd, llmClient.NewAssistantMessageWithToolCalls(toolCalls)) + // 使用带 reasoning_content 的 assistant 消息,满足 DeepSeek thinking mode + tool calls 要求 + messagesToAdd = append(messagesToAdd, llmClient.NewAssistantMessageWithToolCallsAndReasoning(reasoningContent, content, toolCalls)) // 执行每个工具调用 for _, tc := range toolCalls { diff --git a/pkg/llm/service/handler.go b/pkg/llm/service/handler.go index 22aecf01e1..5fbb7c7c25 100644 --- a/pkg/llm/service/handler.go +++ b/pkg/llm/service/handler.go @@ -3,9 +3,12 @@ package service import ( "context" "fmt" + "io" "net/http" + "time" "yunion.io/x/jsonutils" + api "yunion.io/x/onecloud/pkg/apis/llm" "yunion.io/x/onecloud/pkg/appsrv" "yunion.io/x/onecloud/pkg/appsrv/dispatcher" "yunion.io/x/onecloud/pkg/cloudcommon/db" @@ -50,6 +53,64 @@ func handleLLMAvailableNetwork(ctx context.Context, w http.ResponseWriter, r *ht appsrv.SendJSON(w, wrapped) } +func handleDefaultChatStream(ctx context.Context, w http.ResponseWriter, r *http.Request) { + userCred := auth.FetchUserCredential(ctx, policy.FilterPolicyCredential) + if userCred == nil { + httperrors.UnauthorizedError(ctx, w, "Unauthorized") + return + } + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + httperrors.GeneralServerError(ctx, w, err) + return + } + body, err := jsonutils.Parse(bodyBytes) + if err != nil { + httperrors.InvalidInputError(ctx, w, "invalid body: %v", err) + return + } + var input api.LLMMCPAgentRequestInput + if body.Contains(models.GetMCPAgentManager().Keyword()) { + agentObj, _ := body.Get(models.GetMCPAgentManager().Keyword()) + if agentObj != nil { + body = agentObj + } + } + if err := body.Unmarshal(&input); err != nil { + httperrors.InvalidInputError(ctx, w, "invalid input: %v", err) + return + } + defaultAgent, err := models.GetMCPAgentManager().GetDefaultAgent(ctx, userCred) + if err != nil { + httperrors.GeneralServerError(ctx, w, err) + return + } + if defaultAgent == nil { + httperrors.NotFoundError(ctx, w, "no default MCP agent set (set one agent with default_agent=true)") + return + } + query := jsonutils.NewDict() + _, err = defaultAgent.PerformChatStream(ctx, userCred, query, input) + if err != nil { + httperrors.GeneralServerError(ctx, w, err) + return + } +} + +func handleDefaultMcpTools(ctx context.Context, w http.ResponseWriter, r *http.Request) { + userCred := auth.FetchUserCredential(ctx, policy.FilterPolicyCredential) + if userCred == nil { + httperrors.UnauthorizedError(ctx, w, "Unauthorized") + return + } + result, err := models.GetMCPAgentManager().GetDefaultMcpServerTools(ctx, userCred) + if err != nil { + httperrors.GeneralServerError(ctx, w, err) + return + } + appsrv.SendJSON(w, result) +} + func InitHandlers(app *appsrv.Application, isSlave bool) { db.InitAllManagers() db.RegistUserCredCacheUpdater() @@ -60,6 +121,16 @@ func InitHandlers(app *appsrv.Application, isSlave bool) { AddAvailableNetworkHandler(models.GetLLMManager().KeywordPlural(), app) + // 默认 Agent 聊天流:优先于 dispatcher 注册,避免被 performClassAction 的 sendJSON 覆盖。 + // 注册两种路径:default-chat-stream(apigateway 转发用)与 default/chat-stream(climc 直连 region 时用,否则会被当作 resid=default 的 perform 导致 404) + defaultChatStream := app.AddHandler2("POST", "/mcp_agents/default-chat-stream", auth.Authenticate(handleDefaultChatStream), nil, "default_chat_stream", nil) + defaultChatStream.SetProcessTimeout(time.Hour * 4).SetWorkerManager(models.GetMCPAgentWorkerManager()) + defaultChatStreamSlash := app.AddHandler2("POST", "/mcp_agents/default/chat-stream", auth.Authenticate(handleDefaultChatStream), nil, "default_chat_stream_slash", nil) + defaultChatStreamSlash.SetProcessTimeout(time.Hour * 4).SetWorkerManager(models.GetMCPAgentWorkerManager()) + + // 默认 MCP 服务器 tools:仅使用 options.MCPServerURL,不依赖 mcp_agent 条目 + app.AddHandler2("GET", "/mcp_agents/default-mcp-tools", auth.Authenticate(handleDefaultMcpTools), nil, "default_mcp_tools", nil) + for _, manager := range []db.IModelManager{ taskman.TaskManager, taskman.SubTaskManager, diff --git a/pkg/llm/tasks/llm/llm_delete_task.go b/pkg/llm/tasks/llm/llm_delete_task.go index 89c7c843cd..60af179449 100644 --- a/pkg/llm/tasks/llm/llm_delete_task.go +++ b/pkg/llm/tasks/llm/llm_delete_task.go @@ -107,7 +107,13 @@ func (task *LLMDeleteTask) OnLLMContainerDeleteCompleteFailed(ctx context.Contex } func (task *LLMDeleteTask) OnLLMContainerDeleteComplete(ctx context.Context, llm *models.SLLM, body jsonutils.JSONObject) { - err := llm.RealDelete(ctx, task.UserCred) + err := models.GetLLMInstantModelManager().DeleteByLlmId(ctx, llm.Id) + if err != nil { + task.taskFailed(ctx, llm, err) + return + } + + err = llm.RealDelete(ctx, task.UserCred) if err != nil { task.taskFailed(ctx, llm, err) return diff --git a/pkg/mcclient/options/llm/mcp_agent.go b/pkg/mcclient/options/llm/mcp_agent.go index 72218e82a1..2e0d3cc412 100644 --- a/pkg/mcclient/options/llm/mcp_agent.go +++ b/pkg/mcclient/options/llm/mcp_agent.go @@ -14,7 +14,8 @@ import ( type MCPAgentListOptions struct { options.BaseListOptions - LLMDriver string `json:"llm_driver" help:"filter by llm driver (ollama or openai)"` + LLMDriver string `json:"llm_driver" help:"filter by llm driver (ollama or openai)"` + DefaultAgent *bool `json:"default_agent,omitempty" help:"filter by default agent (true to list the default one)"` } func (o *MCPAgentListOptions) Params() (jsonutils.JSONObject, error) { @@ -32,12 +33,13 @@ func (o *MCPAgentShowOptions) Params() (jsonutils.JSONObject, error) { type MCPAgentCreateOptions struct { apis.SharableVirtualResourceCreateInput - LlmId string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id"` - LLM_URL string `help:"后端大模型的 base 请求地址" json:"llm_url"` - LLM_DRIVER string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver" choices:"ollama|openai"` - MODEL string `help:"使用的模型名称" json:"model"` - API_KEY string `help:"访问大模型的密钥" json:"api_key"` - McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"` + LlmId string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id"` + LLM_URL string `help:"后端大模型的 base 请求地址" json:"llm_url"` + LLM_DRIVER string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver" choices:"ollama|openai"` + MODEL string `help:"使用的模型名称" json:"model"` + API_KEY string `help:"访问大模型的密钥" json:"api_key"` + McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"` + DefaultAgent bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent"` } func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) { @@ -45,15 +47,16 @@ func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) { } type MCPAgentUpdateOptions struct { - apis.SharableVirtualResourceCreateInput + apis.SharableVirtualResourceBaseUpdateInput - ID string - LlmId *string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id,omitempty"` - LlmUrl *string `help:"后端大模型的 base 请求地址" json:"llm_url,omitempty"` - LlmDriver *string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver,omitempty" choices:"ollama|openai"` - Model *string `help:"使用的模型名称" json:"model,omitempty"` - ApiKey *string `help:"访问大模型的密钥" json:"api_key,omitempty"` - McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"` + ID string + LlmId *string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id,omitempty"` + LlmUrl *string `help:"后端大模型的 base 请求地址" json:"llm_url,omitempty"` + LlmDriver *string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver,omitempty" choices:"ollama|openai"` + Model *string `help:"使用的模型名称" json:"model,omitempty"` + ApiKey *string `help:"访问大模型的密钥" json:"api_key,omitempty"` + McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"` + DefaultAgent *bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent,omitempty"` } func (o *MCPAgentUpdateOptions) GetId() string { @@ -81,9 +84,12 @@ func (o *MCPAgentUpdateOptions) Params() (jsonutils.JSONObject, error) { if o.McpServer != nil && len(*o.McpServer) > 0 { params.Set("mcp_server", jsonutils.NewString(*o.McpServer)) } + if o.DefaultAgent != nil { + params.Set("default_agent", jsonutils.NewBool(*o.DefaultAgent)) + } // 添加基础字段 - baseParams, err := options.StructToParams(&o.SharableVirtualResourceCreateInput) + baseParams, err := options.StructToParams(&o.SharableVirtualResourceBaseUpdateInput) if err != nil { return nil, err } @@ -169,3 +175,29 @@ func (opts *MCPAgentMCPAgentRequestOptions) Params() (jsonutils.JSONObject, erro return jsonutils.Marshal(input), nil } + +// MCPAgentDefaultChatOptions 用于默认 Agent 聊天(不传 ID,使用 default_agent=true 的条目) +type MCPAgentDefaultChatOptions struct { + MESSAGE string `help:"message to send to MCP agent" json:"message"` + History string `help:"chat history as JSON string, e.g. '[{\"role\":\"user\",\"content\":\"hello\"}]'" json:"history,omitempty"` +} + +func (opts *MCPAgentDefaultChatOptions) Params() (jsonutils.JSONObject, error) { + input := api.LLMMCPAgentRequestInput{ + Message: opts.MESSAGE, + History: []api.MCPAgentChatMessage{}, + } + if len(opts.History) > 0 { + historyJSON, err := jsonutils.ParseString(opts.History) + if err != nil { + return nil, fmt.Errorf("failed to parse history JSON: %v", err) + } + if historyJSON != nil { + err = historyJSON.Unmarshal(&input.History) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal history: %v", err) + } + } + } + return jsonutils.Marshal(input), nil +}