// Agent 运行时与服务端之间的消息、工具与事件桥接。 // // 运行时上报的消息被转换回服务端的 canonical 历史;工具调用统一经 // executeCloudAgentRuntimeTool 执行,与 Go 执行循环共用同一套校验与审批。 package app import ( "context" "encoding/json" "errors" "fmt" "log" "strings" "time" "gorm.io/gorm" "yingce/backend/internal/model" "yingce/backend/internal/repository" ) // canonicalFromRuntimeMessages 把运行时的会话消息(user/assistant/toolResult, // 内容块为 text/image/thinking/toolCall)转换成供应商层接受的规范格式。 const cloudAgentPiCompactionSummaryMessagePrefix = "The conversation history before this point was compacted into the following summary:\n\n\n" const cloudAgentPiCompactionSummaryMessageSuffix = "\n" // strictRuntimeStringAliases resolves protocol aliases without silently // choosing one when two producers disagree. func strictRuntimeStringAliases(fields map[string]interface{}, label string, aliases ...string) (string, error) { value := "" firstAlias := "" for _, alias := range aliases { raw, present := fields[alias] if !present || raw == nil { continue } text, ok := raw.(string) if !ok { return "", fmt.Errorf("%s 字段 %q 必须是字符串", label, alias) } text = strings.TrimSpace(text) if text == "" { continue } if value != "" && value != text { return "", fmt.Errorf("%s 的别名 %q 与 %q 冲突", label, firstAlias, alias) } if value == "" { value, firstAlias = text, alias } } return value, nil } func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string]interface{}, systemPrompt string) (canonicalAgentRequest, error) { canonical := canonicalAgentRequest{Tools: tools, ToolChoice: "auto", SystemPrompt: systemPrompt} for _, message := range messages { switch stringField(message, "role") { case "system": if text := runtimeContentText(message["content"]); strings.TrimSpace(text) != "" { canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "system", "content": text}) } case "compactionSummary": summary := stringField(message, "summary") if summary != "" { canonical.Messages = append(canonical.Messages, map[string]interface{}{ "role": "user", "content": cloudAgentPiCompactionSummaryMessagePrefix + summary + cloudAgentPiCompactionSummaryMessageSuffix, }) } case "user": canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "user", "content": runtimeUserContent(message["content"])}) case "assistant": text := "" calls := []interface{}{} if parts, ok := message["content"].([]interface{}); ok { for _, value := range parts { part, _ := value.(map[string]interface{}) switch stringField(part, "type") { case "text": text += stringField(part, "text") case "toolCall": arguments, _ := json.Marshal(part["arguments"]) if part["arguments"] == nil { arguments = []byte("{}") } callID, err := strictRuntimeStringAliases(part, "Pi 工具调用 ID", "id", "call_id", "toolCallId", "tool_call_id") if err != nil { return canonicalAgentRequest{}, err } if callID == "" { return canonicalAgentRequest{}, errors.New("Pi 工具调用缺少 call id") } itemID, err := strictRuntimeStringAliases(part, "Pi 工具调用 item id", "item_id", "itemId") if err != nil { return canonicalAgentRequest{}, err } call := map[string]interface{}{ "id": callID, "function": map[string]interface{}{"name": stringField(part, "name"), "arguments": string(arguments)}, } if itemID != "" { call["item_id"] = itemID } calls = append(calls, call) } } } else { text = runtimeContentText(message["content"]) } if text == "" && len(calls) == 0 { continue } converted := map[string]interface{}{"role": "assistant", "content": text} if len(calls) > 0 { converted["tool_calls"] = calls } canonical.Messages = append(canonical.Messages, converted) case "toolResult", "tool": id, err := strictRuntimeStringAliases(message, "Pi 工具结果 call id", "toolCallId", "tool_call_id", "call_id") if err != nil { return canonicalAgentRequest{}, err } if id == "" { return canonicalAgentRequest{}, errors.New("Pi 工具结果缺少 call id") } canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "tool", "tool_call_id": id, "content": runtimeContentText(message["content"])}) } } if strings.TrimSpace(systemPrompt) != "" { found := false for _, message := range canonical.Messages { if stringField(message, "role") == "system" { found = true break } } if !found { canonical.Messages = append([]map[string]interface{}{{"role": "system", "content": systemPrompt}}, canonical.Messages...) } } return canonical, nil } func validateRuntimeMessagesForModel(messages []map[string]any) error { pending := make(map[string]struct{}) for _, message := range messages { switch stringField(message, "role") { case "assistant": if len(pending) > 0 { return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) } parts, _ := message["content"].([]interface{}) for _, value := range parts { part, _ := value.(map[string]interface{}) if stringField(part, "type") != "toolCall" { continue } callID, err := strictRuntimeStringAliases(part, "Pi 工具调用 ID", "id", "call_id", "toolCallId", "tool_call_id") if err != nil { return err } if callID == "" { return errors.New("Pi 工具调用缺少 call id") } if _, exists := pending[callID]; exists { return fmt.Errorf("Pi 工具调用 %q 重复或缺少结果", callID) } arguments := part["arguments"] if arguments == nil { arguments = map[string]interface{}{} } encoded, err := json.Marshal(arguments) if err != nil { return fmt.Errorf("Pi 工具调用 %q 参数无效: %w", callID, err) } var object map[string]interface{} if err := json.Unmarshal(encoded, &object); err != nil || object == nil { return fmt.Errorf("Pi 工具调用 %q 参数不是 JSON 对象", callID) } pending[callID] = struct{}{} } case "toolResult", "tool": callID, err := strictRuntimeStringAliases(message, "Pi 工具结果 call id", "toolCallId", "tool_call_id", "call_id") if err != nil { return err } if callID == "" { return errors.New("Pi 工具结果缺少 call id") } if _, exists := pending[callID]; !exists { return fmt.Errorf("Pi 工具结果 %q 没有对应的工具调用", callID) } delete(pending, callID) case "user", "system": if len(pending) > 0 { return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) } } } if len(pending) > 0 { return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) } return nil } func firstPendingRuntimeCall(pending map[string]struct{}) string { for callID := range pending { return callID } return "" } func cloudAgentPiHasCompactionSummary(messages []map[string]any) bool { for _, message := range messages { if stringField(message, "role") == "compactionSummary" && strings.TrimSpace(stringField(message, "summary")) != "" { return true } } return false } func runtimeContentText(content interface{}) string { if text, ok := content.(string); ok { return text } parts, _ := content.([]interface{}) texts := make([]string, 0, len(parts)) for _, value := range parts { part, _ := value.(map[string]interface{}) if stringField(part, "type") == "text" { texts = append(texts, stringField(part, "text")) } } return strings.Join(texts, "\n") } func runtimeUserContent(content interface{}) interface{} { if text, ok := content.(string); ok { return text } parts, _ := content.([]interface{}) converted := make([]interface{}, 0, len(parts)) for _, value := range parts { part, _ := value.(map[string]interface{}) switch stringField(part, "type") { case "text": converted = append(converted, map[string]interface{}{"type": "text", "text": stringField(part, "text")}) case "image": data, mimeType := stringField(part, "data"), stringField(part, "mimeType") if data != "" && mimeType != "" { converted = append(converted, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:" + mimeType + ";base64," + data}}) } case "image_url", "file_url": converted = append(converted, part) } } if len(converted) == 0 { return "" } return converted } // runtimeToolSchemas 把运行时声明的工具转换成规范的 function 工具定义。 func runtimeToolSchemas(tools []map[string]any) []map[string]interface{} { result := make([]map[string]interface{}, 0, len(tools)) for _, tool := range tools { name := stringValue(tool["name"]) if name == "" { continue } parameters := tool["parameters"] if parameters == nil { parameters = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} } result = append(result, map[string]interface{}{ "type": "function", "function": map[string]interface{}{ "name": name, "description": firstNonEmpty(stringValue(tool["description"]), name), "parameters": parameters, }, }) } return result } // runtimeToolCalls 把规范工具调用转换回运行时需要的 {id, name, arguments(object)}。 func runtimeToolCalls(calls []cloudAgentCall) []map[string]any { result := make([]map[string]any, 0, len(calls)) for _, call := range calls { arguments := map[string]any{} if strings.TrimSpace(call.Function.Arguments) != "" { _ = json.Unmarshal([]byte(call.Function.Arguments), &arguments) } entry := map[string]any{"id": call.ID, "name": call.Function.Name, "arguments": arguments} if call.ItemID != "" { entry["item_id"] = call.ItemID } result = append(result, entry) } return result } // cloudAgentPiTool 工具调用桥接:所有工具都交给唯一的 Go 业务执行器 // advanceCloudAgentTool(权限、审批、计费、画布写入都在那里治理)。 func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { callID, err := decodePiStringAliases(payload, "Pi 工具调用 ID", "callId", "call_id", "toolCallId", "tool_call_id", "id") if err != nil { return nil, err } if callID == "" { return nil, errors.New("Pi 工具调用缺少 call id") } name, err := decodePiStringAliases(payload, "Pi 工具名称", "name", "toolName") if err != nil { return nil, err } var call cloudAgentCall call.ID = callID call.Function.Name = name if raw, exists := payload["arguments"]; exists { call.Function.Arguments = strings.TrimSpace(string(raw)) } if call.Function.Arguments == "" || call.Function.Arguments == "null" { call.Function.Arguments = "{}" } if call.Function.Name == "" { return nil, fmt.Errorf("tool call is missing a name") } if err := validateCloudAgentCalls([]cloudAgentCall{call}); err != nil { // 参数校验失败的调用可能已由纠偏提交预写了结构化回执(见 // finishCloudAgentPiModelStepWithInvalidCalls):超限/非法参数进不了执行器, // 但模型仍需要「拆分重试」的结构化指引,而不是这里的一句泛化校验错误—— // 那会让 Pi 会话与服务端 canonical 各记一份不同的结果,下一轮模型请求 // 按历史长度二选一,看到哪份不稳定。优先回放已保存回执,两边保持一致。 if run, loadErr := s.repo.CloudAgent(userID, runID); loadErr == nil { if state, decodeErr := cloudAgentDecode(run); decodeErr == nil { if content, ok := cloudAgentToolMessage(&state, call.ID); ok { return cloudAgentPiToolResult(call, content), nil } } } return nil, err } // 工具白名单校验:拒绝未声明的工具调用 run, err := s.repo.CloudAgent(userID, runID) if err != nil { return nil, err } state, err := cloudAgentDecode(run) if err != nil { return nil, err } allowedTools := make(map[string]bool) for _, tool := range state.Canonical.Tools { if fn, ok := tool["function"].(map[string]interface{}); ok { name := stringValue(fn["name"]) if name != "" { allowedTools[name] = true } } } if !allowedTools[call.Function.Name] { log.Printf("[Agent] rejected undeclared tool call: %s in run %s", call.Function.Name, runID) return nil, fmt.Errorf("未声明的工具: %s", call.Function.Name) } return s.executeCloudAgentRuntimeTool(ctx, userID, runID, call) } func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runID string, call cloudAgentCall) (any, error) { executed := false for attempt := 0; attempt < 8 && !executed; attempt++ { run, err := s.repo.CloudAgent(userID, runID) if err != nil { return nil, err } state, err := cloudAgentDecode(run) if err != nil { return nil, err } // A runtime may retry the same /tool request after the bridge response was // lost. Replay the committed result before inspecting CallIndex: the // executor may already have advanced to the next call or completed the run. if content, ok := cloudAgentToolMessage(&state, call.ID); ok { return cloudAgentPiToolResult(call, content), nil } if cloudAgentRunTerminal(run.Status) { return nil, fmt.Errorf("run already terminated") } if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { return nil, err } if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { return nil, fmt.Errorf("工具调用 %q 没有对应的模型工具调用", call.ID) } expected := state.Calls[state.CallIndex] if !cloudAgentToolCallsEquivalent(expected, call) { return nil, fmt.Errorf("工具调用 %q 与模型声明的工具调用不一致", call.ID) } state.Approval = nil err = s.advanceCloudAgentTool(run, &state) if errors.Is(err, repository.ErrCreationConflict) { time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) continue } if err != nil { return nil, err } executed = true } if !executed { return nil, fmt.Errorf("execute tool %s: %w", call.Function.Name, repository.ErrCreationConflict) } // 媒体生成:等待已提交的任务结束,再由同一执行器回写画布并记录工具结果。 for { run, err := s.repo.CloudAgent(userID, runID) if err != nil { return nil, err } state, err := cloudAgentDecode(run) if err != nil { return nil, err } if run.Status == "waiting_approval" && state.Approval != nil { return map[string]any{"pause": true, "approvalId": state.Approval.ID, "content": "操作正在等待用户审批。"}, nil } if cloudAgentRunTerminal(run.Status) && run.Status != "completed" { return nil, fmt.Errorf("%s", firstNonEmpty(run.FailureMessage, "Agent 工具执行失败")) } if content, ok := cloudAgentToolMessage(&state, call.ID); ok { return cloudAgentPiToolResult(call, content), nil } if state.MediaTaskID == "" { return nil, fmt.Errorf("tool %s produced no result", call.Function.Name) } if _, err := s.waitCloudAgentTask(ctx, state.MediaTaskID); err != nil && ctx.Err() != nil { return nil, ctx.Err() } if err := s.settleCloudAgentMedia(userID, runID); err != nil { return nil, err } } } func cloudAgentToolCallsEquivalent(expected, received cloudAgentCall) bool { if expected.ID == "" || expected.ID != received.ID || expected.Function.Name != received.Function.Name { return false } var expectedArgs, receivedArgs map[string]interface{} if err := json.Unmarshal([]byte(expected.Function.Arguments), &expectedArgs); err != nil || expectedArgs == nil { return false } if err := json.Unmarshal([]byte(received.Function.Arguments), &receivedArgs); err != nil || receivedArgs == nil { return false } left, err := json.Marshal(expectedArgs) if err != nil { return false } right, err := json.Marshal(receivedArgs) return err == nil && string(left) == string(right) } func cloudAgentPiToolResult(call cloudAgentCall, content string) map[string]any { if trimmed := strings.TrimSpace(content); trimmed == "" || trimmed == "null" { content = `{"error":"工具没有返回结果"}` } result := map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)} if call.Function.Name == "ask_user" { var payload map[string]any if json.Unmarshal([]byte(content), &payload) == nil && stringValue(payload["phase"]) == "question" { result["terminate"] = true } } return result } // settleCloudAgentMedia 在媒体任务结束后用唯一的业务执行器回写画布、记录工具结果并释放 // MediaTaskID。任务仍在生成时直接返回;并发写冲突时重读重试。 func (s *Service) settleCloudAgentMedia(userID, runID string) error { for attempt := 0; attempt < 8; attempt++ { latest, err := s.repo.CloudAgent(userID, runID) if err != nil { return err } fresh, err := cloudAgentDecode(latest) if err != nil { return err } if fresh.MediaTaskID == "" || fresh.CallIndex >= len(fresh.Calls) { return nil } err = s.advanceCloudAgentMedia(latest, &fresh, fresh.Calls[fresh.CallIndex]) if !errors.Is(err, repository.ErrCreationConflict) { return err } time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) } return repository.ErrCreationConflict } func cloudAgentToolMessage(state *cloudAgentRuntime, callID string) (string, bool) { for index := len(state.Canonical.Messages) - 1; index >= 0; index-- { message := state.Canonical.Messages[index] if stringField(message, "role") == "tool" && stringField(message, "tool_call_id") == callID { return stringField(message, "content"), true } } return "", false } func cloudAgentToolContentIsError(content string) bool { var decoded map[string]any if json.Unmarshal([]byte(content), &decoded) != nil { return false } _, failed := decoded["error"] return failed } // cloudAgentPiEvent 事件桥接。Node 端事件是平铺字段({type, message, ...}), // 不是 {type, data};这里把整个 payload 作为事件数据交给处理器。 func (s *Service) cloudAgentPiEvent(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { data := map[string]any{} for key, raw := range payload { var value any if err := json.Unmarshal(raw, &value); err != nil { return nil, fmt.Errorf("decode Agent event field %q: %w", key, err) } data[key] = value } eventType, _ := data["type"].(string) if nested, ok := data["data"].(map[string]any); ok { for key, value := range nested { if _, exists := data[key]; !exists { data[key] = value } } } if message, ok := data["message"].(map[string]any); ok { if _, exists := data["role"]; !exists { data["role"] = message["role"] } } switch eventType { case "message_start": return s.handleMessageStart(userID, runID, data) case "message_delta": return s.handleMessageDelta(userID, runID, data) case "message_end": return s.handleMessageEnd(userID, runID, data) case "session_snapshot": return s.handlePiSessionSnapshot(userID, runID, data) case "compaction_start", "compaction_end": kind, eventPayload, ok := cloudAgentPiCompactionEvent(eventType, data) if !ok { return map[string]any{"ok": true}, nil } if err := s.broadcastAgentEvent(userID, runID, kind, eventPayload); err != nil { return nil, err } return map[string]any{"ok": true}, nil case "tool_call", "tool_call_start", "tool_call_end": return s.handleToolCall(userID, runID, data) case "error": return s.handleError(userID, runID, data) default: return map[string]any{"ok": true}, nil } } func cloudAgentPiCompactionEvent(eventType string, data map[string]any) (string, map[string]any, bool) { reason := stringField(data, "reason") contextUsage, _ := data["contextUsage"].(map[string]any) switch eventType { case "compaction_start": payload := map[string]any{ "basis": "tokens", "reason": reason, "contextUsage": contextUsage, } if compactionID := strings.TrimSpace(stringField(data, "compactionId")); compactionID != "" { payload["compactionId"] = compactionID } return "context_compaction_requested", payload, true case "compaction_end": payload := map[string]any{ "reason": reason, "aborted": data["aborted"], "willRetry": data["willRetry"], "contextUsage": contextUsage, } if compactionID := strings.TrimSpace(stringField(data, "compactionId")); compactionID != "" { payload["compactionId"] = compactionID } for _, key := range []string{"tokensBefore", "estimatedTokensAfter"} { if value, exists := data[key]; exists { payload[key] = value } } aborted, _ := data["aborted"].(bool) hasResult, _ := data["hasResult"].(bool) errorMessage := strings.TrimSpace(stringField(data, "errorMessage")) if hasResult && !aborted && errorMessage == "" { return "context_compacted", payload, true } payload["cancelled"] = aborted if aborted { payload["text"] = "上下文压缩已取消,原有对话历史已保留" } else { if errorMessage == "" { errorMessage = "Pi 未能生成上下文摘要" } payload["errorMessage"] = truncateRunes(errorMessage, 1000) payload["text"] = "上下文压缩失败,原有对话历史已保留:" + truncateRunes(errorMessage, 1000) } return "context_compaction_failed", payload, true default: return "", nil, false } } // handlePiSessionSnapshot 持久化 Pi 原生会话 JSONL,用于审批恢复和续轮。 func (s *Service) handlePiSessionSnapshot(userID, runID string, data map[string]any) (any, error) { sessionJSONL, _ := data["sessionJSONL"].(string) if sessionJSONL == "" { return map[string]any{"ok": true}, nil } for attempt := 0; attempt < 4; attempt++ { var expected int64 existing, err := s.repo.CloudAgentPiSession(userID, runID) if err == nil { expected = existing.Revision } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } err = s.repo.SaveCloudAgentPiSession(&model.CloudAgentPiSession{RunID: runID, UserID: userID, SessionJSONL: sessionJSONL, UpdatedAt: time.Now()}, expected) if err == nil { if expected == 0 { // OnConflict DoNothing:并发首写时再按 revision 覆盖一次。 if saved, readErr := s.repo.CloudAgentPiSession(userID, runID); readErr == nil && saved.SessionJSONL != sessionJSONL { continue } } return map[string]any{"ok": true}, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } return nil, fmt.Errorf("save Agent session: %w", repository.ErrCreationConflict) } func stateCanonicalFromInput(input map[string]any) canonicalAgentRequest { canonical, _ := canonicalAgentRequestFromInput(input) return canonical } func canonicalAgentRequestFromInput(input map[string]any) (canonicalAgentRequest, bool) { requests, ok := input["agentRequests"].(map[string]any) if !ok || requests["canonical"] == nil { return canonicalAgentRequest{}, false } raw, err := json.Marshal(requests["canonical"]) if err != nil { return canonicalAgentRequest{}, false } var canonical canonicalAgentRequest if err := json.Unmarshal(raw, &canonical); err != nil { return canonicalAgentRequest{}, false } return canonical, true } func piToolDefinitions(source []map[string]interface{}) []map[string]any { result := make([]map[string]any, 0, len(source)) for _, tool := range source { fn, _ := tool["function"].(map[string]interface{}) name := stringValue(fn["name"]) if name == "" { continue } result = append(result, map[string]any{ "name": name, "label": firstNonEmpty(stringValue(fn["name"]), name), "description": stringValue(fn["description"]), "parameters": fn["parameters"], }) } return result } func isCanvasTool(toolName string) bool { return strings.HasPrefix(toolName, "canvas_") } func decodePiPayload(payload map[string]json.RawMessage, target interface{}) error { // 尝试直接解析整个 payload if data, err := json.Marshal(payload); err == nil { if err := json.Unmarshal(data, target); err == nil { return nil } } // 尝试解析 arguments 字段 if argsRaw, ok := payload["arguments"]; ok { return json.Unmarshal(argsRaw, target) } return fmt.Errorf("failed to decode payload") } func decodePiStringAliases(payload map[string]json.RawMessage, label string, aliases ...string) (string, error) { fields := make(map[string]interface{}, len(payload)) for key, raw := range payload { var value interface{} if err := json.Unmarshal(raw, &value); err != nil { return "", fmt.Errorf("decode Pi field %q: %w", key, err) } fields[key] = value } return strictRuntimeStringAliases(fields, label, aliases...) } func mustMarshal(v any) string { data, _ := json.Marshal(v) return string(data) }