mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
fix: some bugs in mcp-agent & llm (#24355)
* fix(mcp-agent): encoding issue when simulated streaming * fix(mcp-agent): use chat-stream in phase tool-choose * fix(mcp-agent): support reasoning model * fix(llm): add pre-delete detection * feat(mcp-agent): add default mcp-agent * feat(mcp-agent): add default-mcp-tools
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ func (h *MiscHandler) Bind(app *appsrv.Application) {
|
||||
// mcp agent chat stream
|
||||
chatStream := chatHandlerInfo("POST", prefix+"mcp_agents/<id>/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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user