mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
v0.3.8
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
// Package execbridge 负责把执行型工具调用映射为 ExecServerMessage,并归一化 ExecClientMessage 结果。
|
||||
package execbridge
|
||||
@@ -0,0 +1,929 @@
|
||||
// bridge.go 实现 MVP 阶段的交互桥协议映射。
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
readability "codeberg.org/readeck/go-readability/v2"
|
||||
htmlmarkdown "github.com/firecrawl/html-to-markdown"
|
||||
mdplugin "github.com/firecrawl/html-to-markdown/plugin"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/internal/backend/agent/core"
|
||||
"cursor/internal/netproxy"
|
||||
)
|
||||
|
||||
// InteractionApplyResult 表示一次交互桥结果归一化后的最小产物。
|
||||
type InteractionApplyResult struct {
|
||||
// ToolCallID 表示该结果所属工具调用标识。
|
||||
ToolCallID string
|
||||
// InteractionID 表示该结果所属交互桥标识。
|
||||
InteractionID string
|
||||
// IsTerminal 表示交互桥是否已经收口。
|
||||
IsTerminal bool
|
||||
// ToolResultPayload 表示可继续喂给模型的结果摘要。
|
||||
ToolResultPayload string
|
||||
// ToolCall 保存可用于发 ToolCallCompletedUpdate 的工具调用对象。
|
||||
ToolCall *agentv1.ToolCall
|
||||
}
|
||||
|
||||
// InteractionBridge 定义交互桥接口。
|
||||
type InteractionBridge interface {
|
||||
// OpenQuery 打开一条交互型工具调用。
|
||||
OpenQuery(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error)
|
||||
// ApplyInteractionResponse 处理交互响应。
|
||||
ApplyInteractionResponse(msg *agentv1.InteractionResponse, pending runtimecore.PendingInteraction) (InteractionApplyResult, error)
|
||||
}
|
||||
|
||||
// Bridge 实现 MVP 阶段的交互桥。
|
||||
type Bridge struct {
|
||||
// nextID 生成交互消息编号。
|
||||
nextID atomic.Uint32
|
||||
// httpClient 负责执行 web search / web fetch 等需要外网的操作。
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewBridge 创建一个交互桥实例。
|
||||
func NewBridge() *Bridge {
|
||||
return &Bridge{
|
||||
httpClient: netproxy.NewHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// OpenQuery 打开一条交互型工具调用。
|
||||
func (bridge *Bridge) OpenQuery(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
switch toolCall.ToolName {
|
||||
case "AskQuestion":
|
||||
return bridge.openAskQuestion(toolCall)
|
||||
case "CreatePlan":
|
||||
return bridge.openCreatePlan(toolCall)
|
||||
case "WebSearch":
|
||||
return bridge.openWebSearch(toolCall)
|
||||
case "WebFetch":
|
||||
return bridge.openWebFetch(toolCall)
|
||||
case "SwitchMode":
|
||||
return bridge.openSwitchMode(toolCall)
|
||||
default:
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("unsupported interaction tool: %s", toolCall.ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyInteractionResponse 处理交互响应。
|
||||
func (bridge *Bridge) ApplyInteractionResponse(msg *agentv1.InteractionResponse, pending runtimecore.PendingInteraction) (InteractionApplyResult, error) {
|
||||
if msg == nil {
|
||||
return InteractionApplyResult{}, fmt.Errorf("interaction response is required")
|
||||
}
|
||||
|
||||
result := InteractionApplyResult{
|
||||
ToolCallID: pending.ToolCallID,
|
||||
InteractionID: pending.InteractionID,
|
||||
IsTerminal: true,
|
||||
}
|
||||
switch pending.InteractionKind {
|
||||
case "ask_question":
|
||||
var args agentv1.AskQuestionArgs
|
||||
_ = json.Unmarshal(pending.ArgsJSON, &args)
|
||||
result.ToolResultPayload = summarizeAskQuestionResponse(msg.GetAskQuestionInteractionResponse())
|
||||
result.ToolCall = &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_AskQuestionToolCall{
|
||||
AskQuestionToolCall: &agentv1.AskQuestionToolCall{
|
||||
Args: &args,
|
||||
Result: msg.GetAskQuestionInteractionResponse().GetResult(),
|
||||
},
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
case "create_plan":
|
||||
args, err := runtimecore.DecodeCreatePlanArgsJSON(pending.ArgsJSON)
|
||||
if err != nil {
|
||||
args = &agentv1.CreatePlanArgs{}
|
||||
}
|
||||
createPlanResult := normalizeCreatePlanResult(msg.GetCreatePlanRequestResponse())
|
||||
result.ToolResultPayload = summarizeCreatePlanResult(createPlanResult)
|
||||
result.ToolCall = &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_CreatePlanToolCall{
|
||||
CreatePlanToolCall: &agentv1.CreatePlanToolCall{
|
||||
Args: args,
|
||||
Result: createPlanResult,
|
||||
},
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
case "web_search":
|
||||
var args agentv1.WebSearchArgs
|
||||
_ = json.Unmarshal(pending.ArgsJSON, &args)
|
||||
webSearchResult, payload := bridge.applyWebSearchResponse(msg.GetWebSearchRequestResponse(), &args)
|
||||
result.ToolResultPayload = payload
|
||||
result.ToolCall = &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_WebSearchToolCall{
|
||||
WebSearchToolCall: &agentv1.WebSearchToolCall{
|
||||
Args: &args,
|
||||
Result: webSearchResult,
|
||||
},
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
case "web_fetch":
|
||||
var args agentv1.WebFetchArgs
|
||||
_ = json.Unmarshal(pending.ArgsJSON, &args)
|
||||
webFetchResult, payload := bridge.applyWebFetchResponse(msg.GetWebFetchRequestResponse(), &args)
|
||||
result.ToolResultPayload = payload
|
||||
result.ToolCall = &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_WebFetchToolCall{
|
||||
WebFetchToolCall: &agentv1.WebFetchToolCall{
|
||||
Args: &args,
|
||||
Result: webFetchResult,
|
||||
},
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
case "switch_mode":
|
||||
var args agentv1.SwitchModeArgs
|
||||
_ = json.Unmarshal(pending.ArgsJSON, &args)
|
||||
switchModeResult := buildSwitchModeResult(msg.GetSwitchModeRequestResponse(), &args)
|
||||
result.ToolResultPayload = summarizeSwitchModeResponse(switchModeResult)
|
||||
result.ToolCall = &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_SwitchModeToolCall{
|
||||
SwitchModeToolCall: &agentv1.SwitchModeToolCall{
|
||||
Args: &args,
|
||||
Result: switchModeResult,
|
||||
},
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return InteractionApplyResult{}, fmt.Errorf("unsupported pending interaction kind: %s", pending.InteractionKind)
|
||||
}
|
||||
}
|
||||
|
||||
// nextMessageID 返回下一个交互消息编号。
|
||||
func (bridge *Bridge) nextMessageID() uint32 {
|
||||
current := bridge.nextID.Add(1)
|
||||
if current == 0 {
|
||||
current = bridge.nextID.Add(1)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// openAskQuestion 构造 AskQuestion 交互查询。
|
||||
func (bridge *Bridge) openAskQuestion(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
var args agentv1.AskQuestionArgs
|
||||
if err := json.Unmarshal(toolCall.ArgsJSON, &args); err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("decode AskQuestion args failed: %w", err)
|
||||
}
|
||||
messageID := bridge.nextMessageID()
|
||||
serverMessage := &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_InteractionQuery{
|
||||
InteractionQuery: &agentv1.InteractionQuery{
|
||||
Id: messageID,
|
||||
Query: &agentv1.InteractionQuery_AskQuestionInteractionQuery{
|
||||
AskQuestionInteractionQuery: &agentv1.AskQuestionInteractionQuery{
|
||||
Args: &args,
|
||||
ToolCallId: toolCall.CallID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: append([]byte(nil), toolCall.ArgsJSON...),
|
||||
ToolCallID: toolCall.CallID,
|
||||
InteractionKind: "ask_question",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openCreatePlan 构造 CreatePlan 交互查询。
|
||||
func (bridge *Bridge) openCreatePlan(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
args, err := runtimecore.DecodeCreatePlanArgsJSON(toolCall.ArgsJSON)
|
||||
if err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("decode CreatePlan args failed: %w", err)
|
||||
}
|
||||
messageID := bridge.nextMessageID()
|
||||
serverMessage := &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_InteractionQuery{
|
||||
InteractionQuery: &agentv1.InteractionQuery{
|
||||
Id: messageID,
|
||||
Query: &agentv1.InteractionQuery_CreatePlanRequestQuery{
|
||||
CreatePlanRequestQuery: &agentv1.CreatePlanRequestQuery{
|
||||
Args: args,
|
||||
ToolCallId: toolCall.CallID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: append([]byte(nil), toolCall.ArgsJSON...),
|
||||
ToolCallID: toolCall.CallID,
|
||||
InteractionKind: "create_plan",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openWebSearch 构造 WebSearch 交互查询。
|
||||
func (bridge *Bridge) openWebSearch(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
var input struct {
|
||||
SearchTerm string `json:"search_term"`
|
||||
}
|
||||
if err := json.Unmarshal(toolCall.ArgsJSON, &input); err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("decode WebSearch args failed: %w", err)
|
||||
}
|
||||
messageID := bridge.nextMessageID()
|
||||
serverMessage := &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_InteractionQuery{
|
||||
InteractionQuery: &agentv1.InteractionQuery{
|
||||
Id: messageID,
|
||||
Query: &agentv1.InteractionQuery_WebSearchRequestQuery{
|
||||
WebSearchRequestQuery: &agentv1.WebSearchRequestQuery{
|
||||
Args: &agentv1.WebSearchArgs{
|
||||
SearchTerm: input.SearchTerm,
|
||||
ToolCallId: toolCall.CallID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: append([]byte(nil), toolCall.ArgsJSON...),
|
||||
ToolCallID: toolCall.CallID,
|
||||
InteractionKind: "web_search",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openWebFetch 构造 WebFetch 交互查询。
|
||||
func (bridge *Bridge) openWebFetch(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
var input struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(toolCall.ArgsJSON, &input); err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("decode WebFetch args failed: %w", err)
|
||||
}
|
||||
messageID := bridge.nextMessageID()
|
||||
serverMessage := &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_InteractionQuery{
|
||||
InteractionQuery: &agentv1.InteractionQuery{
|
||||
Id: messageID,
|
||||
Query: &agentv1.InteractionQuery_WebFetchRequestQuery{
|
||||
WebFetchRequestQuery: &agentv1.WebFetchRequestQuery{
|
||||
Args: &agentv1.WebFetchArgs{
|
||||
Url: input.URL,
|
||||
ToolCallId: toolCall.CallID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
argsPayload, _ := json.Marshal(agentv1.WebFetchArgs{
|
||||
Url: input.URL,
|
||||
ToolCallId: toolCall.CallID,
|
||||
})
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: argsPayload,
|
||||
ToolCallID: toolCall.CallID,
|
||||
InteractionKind: "web_fetch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openSwitchMode 构造 SwitchMode 交互查询。
|
||||
func (bridge *Bridge) openSwitchMode(toolCall runtimecore.ToolInvocation) (*agentv1.AgentServerMessage, runtimecore.PendingInteraction, error) {
|
||||
var args agentv1.SwitchModeArgs
|
||||
if err := json.Unmarshal(toolCall.ArgsJSON, &args); err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, fmt.Errorf("decode SwitchMode args failed: %w", err)
|
||||
}
|
||||
if err := validateSwitchModeTargetID(args.GetTargetModeId()); err != nil {
|
||||
return nil, runtimecore.PendingInteraction{}, err
|
||||
}
|
||||
args.ToolCallId = toolCall.CallID
|
||||
messageID := bridge.nextMessageID()
|
||||
serverMessage := &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_InteractionQuery{
|
||||
InteractionQuery: &agentv1.InteractionQuery{
|
||||
Id: messageID,
|
||||
Query: &agentv1.InteractionQuery_SwitchModeRequestQuery{
|
||||
SwitchModeRequestQuery: &agentv1.SwitchModeRequestQuery{
|
||||
Args: &args,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
argsPayload, _ := json.Marshal(args)
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: argsPayload,
|
||||
ToolCallID: toolCall.CallID,
|
||||
InteractionKind: "switch_mode",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateSwitchModeTargetID(raw string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "agent", "ask", "plan":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported target mode id: %q", strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeAskQuestionResponse 生成 AskQuestion 响应摘要。
|
||||
func summarizeAskQuestionResponse(response *agentv1.AskQuestionInteractionResponse) string {
|
||||
if response == nil || response.GetResult() == nil {
|
||||
return "ask question response missing"
|
||||
}
|
||||
switch item := response.GetResult().GetResult().(type) {
|
||||
case *agentv1.AskQuestionResult_Success:
|
||||
if len(item.Success.GetAnswers()) == 0 {
|
||||
return "ask question success"
|
||||
}
|
||||
return fmt.Sprintf("ask question answers=%d", len(item.Success.GetAnswers()))
|
||||
case *agentv1.AskQuestionResult_Error:
|
||||
return item.Error.GetErrorMessage()
|
||||
case *agentv1.AskQuestionResult_Rejected:
|
||||
return item.Rejected.GetReason()
|
||||
case *agentv1.AskQuestionResult_Async:
|
||||
return "ask question async accepted"
|
||||
default:
|
||||
return "unknown ask question response"
|
||||
}
|
||||
}
|
||||
|
||||
const createPlanEmptyURIError = "create plan failed: Cursor returned success with empty planUri"
|
||||
|
||||
// normalizeCreatePlanResult 兜底客户端 success 但未返回 planUri 的异常形态。
|
||||
func normalizeCreatePlanResult(response *agentv1.CreatePlanRequestResponse) *agentv1.CreatePlanResult {
|
||||
if response == nil || response.GetResult() == nil {
|
||||
return nil
|
||||
}
|
||||
result := response.GetResult()
|
||||
if result.GetSuccess() != nil && strings.TrimSpace(result.GetPlanUri()) == "" {
|
||||
return &agentv1.CreatePlanResult{
|
||||
Result: &agentv1.CreatePlanResult_Error{
|
||||
Error: &agentv1.CreatePlanError{Error: createPlanEmptyURIError},
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// summarizeCreatePlanResult 生成 CreatePlan 响应摘要。
|
||||
func summarizeCreatePlanResult(result *agentv1.CreatePlanResult) string {
|
||||
if result == nil {
|
||||
return "create plan response missing"
|
||||
}
|
||||
switch item := result.GetResult().(type) {
|
||||
case *agentv1.CreatePlanResult_Success:
|
||||
return fmt.Sprintf("create plan success uri=%s", result.GetPlanUri())
|
||||
case *agentv1.CreatePlanResult_Error:
|
||||
return item.Error.GetError()
|
||||
default:
|
||||
return "unknown create plan response"
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeWebSearchResponse 生成 WebSearch 响应摘要。
|
||||
func summarizeWebSearchResponse(response *agentv1.WebSearchRequestResponse) string {
|
||||
if response == nil {
|
||||
return "web search response missing"
|
||||
}
|
||||
switch item := response.GetResult().(type) {
|
||||
case *agentv1.WebSearchRequestResponse_Approved_:
|
||||
_ = item
|
||||
return "web search approved"
|
||||
case *agentv1.WebSearchRequestResponse_Rejected_:
|
||||
return item.Rejected.GetReason()
|
||||
default:
|
||||
return "unknown web search response"
|
||||
}
|
||||
}
|
||||
|
||||
// applyWebSearchResponse 把 WebSearch approval 响应转换成最终工具结果。
|
||||
func (bridge *Bridge) applyWebSearchResponse(response *agentv1.WebSearchRequestResponse, args *agentv1.WebSearchArgs) (*agentv1.WebSearchResult, string) {
|
||||
if response == nil {
|
||||
return &agentv1.WebSearchResult{
|
||||
Result: &agentv1.WebSearchResult_Error{
|
||||
Error: &agentv1.WebSearchError{Error: "web search response missing"},
|
||||
},
|
||||
}, "web search response missing"
|
||||
}
|
||||
switch item := response.GetResult().(type) {
|
||||
case *agentv1.WebSearchRequestResponse_Approved_:
|
||||
_ = item
|
||||
references, payload, err := bridge.executeWebSearch(strings.TrimSpace(args.GetSearchTerm()))
|
||||
if err != nil {
|
||||
return &agentv1.WebSearchResult{
|
||||
Result: &agentv1.WebSearchResult_Error{
|
||||
Error: &agentv1.WebSearchError{Error: err.Error()},
|
||||
},
|
||||
}, err.Error()
|
||||
}
|
||||
references, payload = truncateWebSearchReplay(strings.TrimSpace(args.GetSearchTerm()), references, payload)
|
||||
return &agentv1.WebSearchResult{
|
||||
Result: &agentv1.WebSearchResult_Success{
|
||||
Success: &agentv1.WebSearchSuccess{References: references},
|
||||
},
|
||||
}, payload
|
||||
case *agentv1.WebSearchRequestResponse_Rejected_:
|
||||
return &agentv1.WebSearchResult{
|
||||
Result: &agentv1.WebSearchResult_Rejected{
|
||||
Rejected: &agentv1.WebSearchRejected{Reason: item.Rejected.GetReason()},
|
||||
},
|
||||
}, item.Rejected.GetReason()
|
||||
default:
|
||||
return &agentv1.WebSearchResult{
|
||||
Result: &agentv1.WebSearchResult_Error{
|
||||
Error: &agentv1.WebSearchError{Error: "unknown web search response"},
|
||||
},
|
||||
}, "unknown web search response"
|
||||
}
|
||||
}
|
||||
|
||||
// applyWebFetchResponse 把 WebFetch approval 响应转换成最终工具结果。
|
||||
func (bridge *Bridge) applyWebFetchResponse(response *agentv1.WebFetchRequestResponse, args *agentv1.WebFetchArgs) (*agentv1.WebFetchResult, string) {
|
||||
if response == nil {
|
||||
return &agentv1.WebFetchResult{
|
||||
Result: &agentv1.WebFetchResult_Error{
|
||||
Error: &agentv1.WebFetchError{
|
||||
Url: args.GetUrl(),
|
||||
Error: "web fetch response missing",
|
||||
},
|
||||
},
|
||||
}, "web fetch response missing"
|
||||
}
|
||||
switch item := response.GetResult().(type) {
|
||||
case *agentv1.WebFetchRequestResponse_Approved_:
|
||||
_ = item
|
||||
markdown, err := bridge.executeWebFetch(strings.TrimSpace(args.GetUrl()))
|
||||
if err != nil {
|
||||
return &agentv1.WebFetchResult{
|
||||
Result: &agentv1.WebFetchResult_Error{
|
||||
Error: &agentv1.WebFetchError{
|
||||
Url: args.GetUrl(),
|
||||
Error: err.Error(),
|
||||
},
|
||||
},
|
||||
}, err.Error()
|
||||
}
|
||||
return &agentv1.WebFetchResult{
|
||||
Result: &agentv1.WebFetchResult_Success{
|
||||
Success: &agentv1.WebFetchSuccess{
|
||||
Url: args.GetUrl(),
|
||||
Markdown: markdown,
|
||||
},
|
||||
},
|
||||
}, markdown
|
||||
case *agentv1.WebFetchRequestResponse_Rejected_:
|
||||
return &agentv1.WebFetchResult{
|
||||
Result: &agentv1.WebFetchResult_Rejected{
|
||||
Rejected: &agentv1.WebFetchRejected{Reason: item.Rejected.GetReason()},
|
||||
},
|
||||
}, item.Rejected.GetReason()
|
||||
default:
|
||||
return &agentv1.WebFetchResult{
|
||||
Result: &agentv1.WebFetchResult_Error{
|
||||
Error: &agentv1.WebFetchError{
|
||||
Url: args.GetUrl(),
|
||||
Error: "unknown web fetch response",
|
||||
},
|
||||
},
|
||||
}, "unknown web fetch response"
|
||||
}
|
||||
}
|
||||
|
||||
// buildSwitchModeResult 把 SwitchMode approval 响应转换成最终工具结果。
|
||||
func buildSwitchModeResult(response *agentv1.SwitchModeRequestResponse, args *agentv1.SwitchModeArgs) *agentv1.SwitchModeResult {
|
||||
if response == nil {
|
||||
return &agentv1.SwitchModeResult{
|
||||
Result: &agentv1.SwitchModeResult_Error{
|
||||
Error: &agentv1.SwitchModeError{Error: "switch mode response missing"},
|
||||
},
|
||||
}
|
||||
}
|
||||
switch item := response.GetResult().(type) {
|
||||
case *agentv1.SwitchModeRequestResponse_Approved_:
|
||||
_ = item
|
||||
targetModeID := strings.ToLower(strings.TrimSpace(args.GetTargetModeId()))
|
||||
return &agentv1.SwitchModeResult{
|
||||
Result: &agentv1.SwitchModeResult_Success{
|
||||
Success: &agentv1.SwitchModeSuccess{
|
||||
FromModeId: "unknown",
|
||||
ToModeId: targetModeID,
|
||||
},
|
||||
},
|
||||
}
|
||||
case *agentv1.SwitchModeRequestResponse_Rejected_:
|
||||
return &agentv1.SwitchModeResult{
|
||||
Result: &agentv1.SwitchModeResult_Rejected{
|
||||
Rejected: &agentv1.SwitchModeRejected{Reason: item.Rejected.GetReason()},
|
||||
},
|
||||
}
|
||||
default:
|
||||
return &agentv1.SwitchModeResult{
|
||||
Result: &agentv1.SwitchModeResult_Error{
|
||||
Error: &agentv1.SwitchModeError{Error: "unknown switch mode response"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeSwitchModeResponse 生成 SwitchMode 响应摘要。
|
||||
func summarizeSwitchModeResponse(result *agentv1.SwitchModeResult) string {
|
||||
if result == nil {
|
||||
return "switch mode result missing"
|
||||
}
|
||||
switch item := result.GetResult().(type) {
|
||||
case *agentv1.SwitchModeResult_Success:
|
||||
return fmt.Sprintf("switch mode success to=%s", item.Success.GetToModeId())
|
||||
case *agentv1.SwitchModeResult_Rejected:
|
||||
return item.Rejected.GetReason()
|
||||
case *agentv1.SwitchModeResult_Error:
|
||||
return item.Error.GetError()
|
||||
default:
|
||||
return "unknown switch mode result"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
webSearchAnchorPattern = regexp.MustCompile(`(?is)<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
webSearchSnippetPattern = regexp.MustCompile(`(?is)<(?:a|div)[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</(?:a|div)>`)
|
||||
htmlTitlePattern = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
webSearchURLOverride = "https://html.duckduckgo.com/html/?q="
|
||||
)
|
||||
|
||||
const (
|
||||
webFetchBodyLimit = 2 * 1024 * 1024
|
||||
webFetchMarkdownLimit = 32 * 1024
|
||||
webSearchPayloadLimit = 16 * 1024
|
||||
webSearchTitleLimit = 512
|
||||
webSearchChunkLimit = 2 * 1024
|
||||
)
|
||||
|
||||
func (bridge *Bridge) executeWebSearch(searchTerm string) ([]*agentv1.WebSearchReference, string, error) {
|
||||
if strings.TrimSpace(searchTerm) == "" {
|
||||
return nil, "", fmt.Errorf("web search search_term is required")
|
||||
}
|
||||
client := bridge.httpClient
|
||||
if client == nil {
|
||||
client = netproxy.NewHTTPClient(15 * time.Second)
|
||||
}
|
||||
requestURL := webSearchURLOverride + neturl.QueryEscape(searchTerm)
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
request.Header.Set("User-Agent", "cursor-local-agent/1.0")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("web search http status %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
references := extractWebSearchReferences(string(body))
|
||||
if len(references) == 0 {
|
||||
return nil, "", fmt.Errorf("web search returned no parseable results")
|
||||
}
|
||||
if len(references) > 5 {
|
||||
references = references[:5]
|
||||
}
|
||||
return references, formatWebSearchPayload(searchTerm, references), nil
|
||||
}
|
||||
|
||||
func extractWebSearchReferences(body string) []*agentv1.WebSearchReference {
|
||||
anchorMatches := webSearchAnchorPattern.FindAllStringSubmatch(body, 8)
|
||||
snippetMatches := webSearchSnippetPattern.FindAllStringSubmatch(body, 8)
|
||||
references := make([]*agentv1.WebSearchReference, 0, len(anchorMatches))
|
||||
for index, match := range anchorMatches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
title := cleanupWebSearchHTML(match[2])
|
||||
url := strings.TrimSpace(html.UnescapeString(match[1]))
|
||||
snippet := ""
|
||||
if index < len(snippetMatches) && len(snippetMatches[index]) >= 2 {
|
||||
snippet = cleanupWebSearchHTML(snippetMatches[index][1])
|
||||
}
|
||||
if title == "" || url == "" {
|
||||
continue
|
||||
}
|
||||
references = append(references, &agentv1.WebSearchReference{
|
||||
Title: title,
|
||||
Url: url,
|
||||
Chunk: snippet,
|
||||
})
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
func cleanupWebSearchHTML(value string) string {
|
||||
withoutTags := htmlTagPattern.ReplaceAllString(value, " ")
|
||||
unescaped := html.UnescapeString(withoutTags)
|
||||
return strings.Join(strings.Fields(unescaped), " ")
|
||||
}
|
||||
|
||||
func formatWebSearchPayload(searchTerm string, references []*agentv1.WebSearchReference) string {
|
||||
lines := []string{
|
||||
fmt.Sprintf("Title: Web search results for query: %s", strings.TrimSpace(searchTerm)),
|
||||
"Content: Links:",
|
||||
}
|
||||
for index, reference := range references {
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. [%s](%s)", index+1, strings.TrimSpace(reference.GetTitle()), strings.TrimSpace(reference.GetUrl())))
|
||||
}
|
||||
snippets := make([]string, 0, len(references))
|
||||
for _, reference := range references {
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
chunk := strings.TrimSpace(reference.GetChunk())
|
||||
if chunk == "" {
|
||||
continue
|
||||
}
|
||||
snippets = append(snippets, fmt.Sprintf("- %s", chunk))
|
||||
}
|
||||
if len(snippets) > 0 {
|
||||
lines = append(lines, "", strings.Join(snippets, "\n"))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func truncateWebSearchReplay(searchTerm string, references []*agentv1.WebSearchReference, payload string) ([]*agentv1.WebSearchReference, string) {
|
||||
truncated := false
|
||||
nextReferences := make([]*agentv1.WebSearchReference, 0, len(references))
|
||||
for _, reference := range references {
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
next := *reference
|
||||
title := truncateInteractionText("WebSearch title", next.GetTitle(), webSearchTitleLimit)
|
||||
chunk := truncateInteractionText("WebSearch snippet", next.GetChunk(), webSearchChunkLimit)
|
||||
if title != next.GetTitle() || chunk != next.GetChunk() {
|
||||
truncated = true
|
||||
}
|
||||
next.Title = title
|
||||
next.Chunk = chunk
|
||||
nextReferences = append(nextReferences, &next)
|
||||
}
|
||||
nextPayload := formatWebSearchPayload(searchTerm, nextReferences)
|
||||
if strings.TrimSpace(payload) != "" && len(nextPayload) == 0 {
|
||||
nextPayload = payload
|
||||
}
|
||||
if len(nextPayload) > webSearchPayloadLimit {
|
||||
truncated = true
|
||||
nextPayload = truncateInteractionText("WebSearch", nextPayload, webSearchPayloadLimit)
|
||||
}
|
||||
if truncated && len(nextReferences) > 0 {
|
||||
last := nextReferences[len(nextReferences)-1]
|
||||
last.Chunk = strings.TrimSpace(last.GetChunk() + "\n\n" + interactionTruncationNotice("WebSearch", webSearchPayloadLimit, len(nextPayload), len(payload)))
|
||||
nextPayload = formatWebSearchPayload(searchTerm, nextReferences)
|
||||
nextPayload = truncateInteractionText("WebSearch", nextPayload, webSearchPayloadLimit)
|
||||
}
|
||||
return nextReferences, nextPayload
|
||||
}
|
||||
|
||||
func (bridge *Bridge) executeWebFetch(rawURL string) (string, error) {
|
||||
parsedURL, err := validateWebFetchURL(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := bridge.httpClient
|
||||
if client == nil {
|
||||
client = netproxy.NewHTTPClient(15 * time.Second)
|
||||
}
|
||||
client = webFetchHTTPClient(client)
|
||||
request, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("User-Agent", "cursor-local-agent/1.0")
|
||||
request.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain,application/xml,application/json;q=0.9,*/*;q=0.1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("web fetch http status %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, webFetchBodyLimit+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("web fetch returned empty body")
|
||||
}
|
||||
if len(body) > webFetchBodyLimit {
|
||||
body = body[:webFetchBodyLimit]
|
||||
}
|
||||
contentType := response.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(body)
|
||||
}
|
||||
if !isWebFetchTextContentType(contentType) {
|
||||
return "", fmt.Errorf("web fetch unsupported content type %q", contentType)
|
||||
}
|
||||
markdown, title, err := renderWebFetchMarkdown(parsedURL, body, contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
markdown = strings.TrimSpace(markdown)
|
||||
if markdown == "" {
|
||||
return "", fmt.Errorf("web fetch returned empty markdown")
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = parsedURL.String()
|
||||
}
|
||||
payload := fmt.Sprintf("Title: %s\nURL: %s\n\nContent:\n%s", title, parsedURL.String(), markdown)
|
||||
return truncateWebFetchMarkdown(payload), nil
|
||||
}
|
||||
|
||||
func validateWebFetchURL(rawURL string) (*neturl.URL, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("web fetch url is required")
|
||||
}
|
||||
parsedURL, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web fetch invalid url: %w", err)
|
||||
}
|
||||
switch strings.ToLower(parsedURL.Scheme) {
|
||||
case "http", "https":
|
||||
default:
|
||||
return nil, fmt.Errorf("web fetch only supports http and https urls")
|
||||
}
|
||||
host := strings.TrimSpace(parsedURL.Hostname())
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("web fetch url host is required")
|
||||
}
|
||||
if isBlockedWebFetchHost(host) {
|
||||
return nil, fmt.Errorf("web fetch host is not public-web accessible")
|
||||
}
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
func isBlockedWebFetchHost(host string) bool {
|
||||
host = strings.Trim(strings.ToLower(host), "[]")
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func isWebFetchTextContentType(contentType string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "text/") {
|
||||
return true
|
||||
}
|
||||
switch mediaType {
|
||||
case "application/xhtml+xml", "application/xml", "application/json", "application/ld+json", "application/rss+xml", "application/atom+xml":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(mediaType, "+xml") || strings.HasSuffix(mediaType, "+json")
|
||||
}
|
||||
}
|
||||
|
||||
func renderWebFetchMarkdown(pageURL *neturl.URL, body []byte, contentType string) (string, string, error) {
|
||||
if !isHTMLLikeContentType(contentType) {
|
||||
return string(body), "", nil
|
||||
}
|
||||
article, err := readability.FromReader(bytes.NewReader(body), pageURL)
|
||||
if err == nil {
|
||||
var articleHTML bytes.Buffer
|
||||
if renderErr := article.RenderHTML(&articleHTML); renderErr == nil && strings.TrimSpace(articleHTML.String()) != "" {
|
||||
if markdown, convertErr := convertHTMLToMarkdown(pageURL, articleHTML.String()); convertErr == nil && strings.TrimSpace(markdown) != "" {
|
||||
return markdown, article.Title(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
markdown, err := convertHTMLToMarkdown(pageURL, string(body))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("web fetch markdown conversion failed: %w", err)
|
||||
}
|
||||
return markdown, extractWebFetchHTMLTitle(string(body)), nil
|
||||
}
|
||||
|
||||
func isHTMLLikeContentType(contentType string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
}
|
||||
return mediaType == "text/html" || mediaType == "application/xhtml+xml" || mediaType == ""
|
||||
}
|
||||
|
||||
func convertHTMLToMarkdown(pageURL *neturl.URL, htmlBody string) (string, error) {
|
||||
converter := htmlmarkdown.NewConverter(htmlmarkdown.DomainFromURL(pageURL.String()), true, nil)
|
||||
converter.Use(mdplugin.GitHubFlavored())
|
||||
return converter.ConvertString(htmlBody)
|
||||
}
|
||||
|
||||
func extractWebFetchHTMLTitle(htmlBody string) string {
|
||||
matches := htmlTitlePattern.FindStringSubmatch(htmlBody)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return cleanupWebSearchHTML(matches[1])
|
||||
}
|
||||
|
||||
func truncateWebFetchMarkdown(markdown string) string {
|
||||
return truncateInteractionText("WebFetch", markdown, webFetchMarkdownLimit)
|
||||
}
|
||||
|
||||
func truncateInteractionText(toolName string, text string, limit int) string {
|
||||
if limit <= 0 || len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
original := len(text)
|
||||
notice := fmt.Sprintf("\n\n%s", interactionTruncationNotice(toolName, limit, limit, original))
|
||||
for {
|
||||
keep := limit - len(notice)
|
||||
if keep <= 0 {
|
||||
return truncateInteractionUTF8(text, limit)
|
||||
}
|
||||
kept := truncateInteractionUTF8(text, keep)
|
||||
nextNotice := fmt.Sprintf("\n\n%s", interactionTruncationNotice(toolName, limit, len(kept), original))
|
||||
output := strings.TrimRight(kept, "\n") + nextNotice
|
||||
if len(output) <= limit || nextNotice == notice {
|
||||
return output
|
||||
}
|
||||
notice = nextNotice
|
||||
}
|
||||
}
|
||||
|
||||
func interactionTruncationNotice(toolName string, limit int, kept int, original int) string {
|
||||
return fmt.Sprintf("[truncated: %s result exceeded %d bytes; showing %d of %d bytes]", toolName, limit, kept, original)
|
||||
}
|
||||
|
||||
func truncateInteractionUTF8(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit > len(text) {
|
||||
limit = len(text)
|
||||
}
|
||||
truncated := text[:limit]
|
||||
for !utf8.ValidString(truncated) && len(truncated) > 0 {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
return truncated
|
||||
}
|
||||
|
||||
func webFetchHTTPClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
base = netproxy.NewHTTPClient(15 * time.Second)
|
||||
}
|
||||
client := *base
|
||||
previousCheckRedirect := client.CheckRedirect
|
||||
client.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("web fetch stopped after 10 redirects")
|
||||
}
|
||||
if _, err := validateWebFetchURL(request.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
if previousCheckRedirect != nil {
|
||||
return previousCheckRedirect(request, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &client
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package interaction 负责把交互型工具调用映射为 InteractionQuery,并归一化 InteractionResponse。
|
||||
package interaction
|
||||
@@ -0,0 +1,237 @@
|
||||
package runtimecore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
// DecodeCreatePlanArgsJSON 解析 CreatePlan 参数,并兼容字符串形式的 todo status。
|
||||
func DecodeCreatePlanArgsJSON(raw []byte) (*agentv1.CreatePlanArgs, error) {
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return &agentv1.CreatePlanArgs{}, nil
|
||||
}
|
||||
|
||||
var direct agentv1.CreatePlanArgs
|
||||
if err := json.Unmarshal(raw, &direct); err == nil {
|
||||
return &direct, nil
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload == nil {
|
||||
return &agentv1.CreatePlanArgs{}, nil
|
||||
}
|
||||
|
||||
todos, err := decodeCreatePlanTodoItems(createPlanValueByAlias(payload, "todos"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode todos: %w", err)
|
||||
}
|
||||
phases, err := decodeCreatePlanPhases(createPlanValueByAlias(payload, "phases"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode phases: %w", err)
|
||||
}
|
||||
|
||||
return &agentv1.CreatePlanArgs{
|
||||
Plan: createPlanStringValue(createPlanValueByAlias(payload, "plan")),
|
||||
Overview: createPlanStringValue(createPlanValueByAlias(payload, "overview")),
|
||||
Name: strings.TrimSpace(createPlanStringValue(createPlanValueByAlias(payload, "name"))),
|
||||
IsProject: createPlanBoolValue(createPlanValueByAlias(payload, "is_project", "isProject")),
|
||||
Todos: todos,
|
||||
Phases: phases,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeCreatePlanPhases(value any) ([]*agentv1.Phase, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("phases must be an array")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
phases := make([]*agentv1.Phase, 0, len(items))
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("phase %d must be an object", index)
|
||||
}
|
||||
todos, err := decodeCreatePlanTodoItems(createPlanValueByAlias(object, "todos"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("phase %d todos: %w", index, err)
|
||||
}
|
||||
phases = append(phases, &agentv1.Phase{
|
||||
Name: strings.TrimSpace(createPlanStringValue(createPlanValueByAlias(object, "name"))),
|
||||
Todos: todos,
|
||||
})
|
||||
}
|
||||
return phases, nil
|
||||
}
|
||||
|
||||
func decodeCreatePlanTodoItems(value any) ([]*agentv1.TodoItem, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("todos must be an array")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
todos := make([]*agentv1.TodoItem, 0, len(items))
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("todo %d must be an object", index)
|
||||
}
|
||||
status, err := decodeCreatePlanTodoStatus(createPlanValueByAlias(object, "status"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("todo %d status: %w", index, err)
|
||||
}
|
||||
todos = append(todos, &agentv1.TodoItem{
|
||||
Id: strings.TrimSpace(createPlanStringValue(createPlanValueByAlias(object, "id"))),
|
||||
Content: strings.TrimSpace(createPlanStringValue(createPlanValueByAlias(object, "content"))),
|
||||
Status: status,
|
||||
CreatedAt: createPlanInt64Value(createPlanValueByAlias(object, "created_at", "createdAt")),
|
||||
UpdatedAt: createPlanInt64Value(createPlanValueByAlias(object, "updated_at", "updatedAt")),
|
||||
Dependencies: createPlanStringSliceValue(createPlanValueByAlias(object, "dependencies")),
|
||||
})
|
||||
}
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func decodeCreatePlanTodoStatus(value any) (agentv1.TodoStatus, error) {
|
||||
switch item := value.(type) {
|
||||
case nil:
|
||||
return agentv1.TodoStatus_TODO_STATUS_UNSPECIFIED, nil
|
||||
case float64:
|
||||
return agentv1.TodoStatus(int32(item)), nil
|
||||
case float32:
|
||||
return agentv1.TodoStatus(int32(item)), nil
|
||||
case int:
|
||||
return agentv1.TodoStatus(item), nil
|
||||
case int32:
|
||||
return agentv1.TodoStatus(item), nil
|
||||
case int64:
|
||||
return agentv1.TodoStatus(item), nil
|
||||
case string:
|
||||
return decodeCreatePlanTodoStatusString(item)
|
||||
default:
|
||||
return agentv1.TodoStatus_TODO_STATUS_UNSPECIFIED, fmt.Errorf("unsupported todo status type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCreatePlanTodoStatusString(raw string) (agentv1.TodoStatus, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" || normalized == "unspecified" || normalized == "todo_status_unspecified" {
|
||||
return agentv1.TodoStatus_TODO_STATUS_UNSPECIFIED, nil
|
||||
}
|
||||
if numeric, err := strconv.ParseInt(normalized, 10, 32); err == nil {
|
||||
return agentv1.TodoStatus(numeric), nil
|
||||
}
|
||||
switch normalized {
|
||||
case "pending", "todo_status_pending":
|
||||
return agentv1.TodoStatus_TODO_STATUS_PENDING, nil
|
||||
case "in_progress", "in-progress", "inprogress", "todo_status_in_progress":
|
||||
return agentv1.TodoStatus_TODO_STATUS_IN_PROGRESS, nil
|
||||
case "completed", "complete", "todo_status_completed":
|
||||
return agentv1.TodoStatus_TODO_STATUS_COMPLETED, nil
|
||||
case "cancelled", "canceled", "todo_status_cancelled":
|
||||
return agentv1.TodoStatus_TODO_STATUS_CANCELLED, nil
|
||||
default:
|
||||
return agentv1.TodoStatus_TODO_STATUS_UNSPECIFIED, fmt.Errorf("unsupported todo status %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func createPlanValueByAlias(payload map[string]any, aliases ...string) any {
|
||||
for _, alias := range aliases {
|
||||
if value, ok := payload[alias]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createPlanStringValue(value any) string {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func createPlanBoolValue(value any) bool {
|
||||
switch item := value.(type) {
|
||||
case bool:
|
||||
return item
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(item), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func createPlanInt64Value(value any) int64 {
|
||||
switch item := value.(type) {
|
||||
case float64:
|
||||
return int64(item)
|
||||
case float32:
|
||||
return int64(item)
|
||||
case int:
|
||||
return int64(item)
|
||||
case int32:
|
||||
return int64(item)
|
||||
case int64:
|
||||
return item
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func createPlanStringSliceValue(value any) []string {
|
||||
switch item := value.(type) {
|
||||
case []string:
|
||||
if len(item) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(item))
|
||||
for _, text := range item {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
if len(item) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(item))
|
||||
for _, entry := range item {
|
||||
text := strings.TrimSpace(createPlanStringValue(entry))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package runtimecore 定义 runtime/loop、checkpoint、session 之间共享的状态与事件模型。
|
||||
package runtimecore
|
||||
@@ -0,0 +1,110 @@
|
||||
package runtimecore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MCPToolPayload 表示 CallMcpTool 的宽容解码结果。
|
||||
type MCPToolPayload struct {
|
||||
Server string
|
||||
ProviderIdentifier string
|
||||
ToolName string
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
// DecodeMCPToolPayload 解析 CallMcpTool 参数,并兼容字符串化的 arguments 对象。
|
||||
func DecodeMCPToolPayload(raw []byte) (MCPToolPayload, error) {
|
||||
payload := MCPToolPayload{
|
||||
Arguments: make(map[string]any),
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if decoded == nil {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
payload.Server = decodeJSONStringValue(decoded["server"])
|
||||
payload.ProviderIdentifier = decodeJSONStringValue(decoded["providerIdentifier"])
|
||||
payload.ToolName = decodeJSONStringValue(decoded["toolName"])
|
||||
payload.Name = decodeJSONStringValue(decoded["name"])
|
||||
payload.Arguments = decodeJSONObjectLike(decoded["arguments"])
|
||||
if len(payload.Arguments) == 0 {
|
||||
payload.Arguments = decodeJSONObjectLike(decoded["args"])
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// InferMCPServerIdentifier 从 canonical lookup name 中反推出 server identifier。
|
||||
func InferMCPServerIdentifier(name string) string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if index := strings.Index(trimmed, "-"); index > 0 {
|
||||
return strings.TrimSpace(trimmed[:index])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InferMCPToolName 从 canonical lookup name 中反推出 tool name。
|
||||
func InferMCPToolName(serverIdentifier string, name string) string {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName == "" {
|
||||
return ""
|
||||
}
|
||||
trimmedServer := strings.TrimSpace(serverIdentifier)
|
||||
if trimmedServer != "" && strings.HasPrefix(trimmedName, trimmedServer+"-") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(trimmedName, trimmedServer+"-"))
|
||||
}
|
||||
return trimmedName
|
||||
}
|
||||
|
||||
func decodeJSONStringValue(value any) string {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func decodeJSONObjectLike(value any) map[string]any {
|
||||
switch item := value.(type) {
|
||||
case map[string]any:
|
||||
if item == nil {
|
||||
return make(map[string]any)
|
||||
}
|
||||
return item
|
||||
case string:
|
||||
return decodeJSONObjectBytes([]byte(item))
|
||||
case []byte:
|
||||
return decodeJSONObjectBytes(item)
|
||||
case json.RawMessage:
|
||||
return decodeJSONObjectBytes([]byte(item))
|
||||
default:
|
||||
return make(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSONObjectBytes(raw []byte) map[string]any {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" {
|
||||
return make(map[string]any)
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return make(map[string]any)
|
||||
}
|
||||
object, ok := decoded.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return make(map[string]any)
|
||||
}
|
||||
return object
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package runtimecore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DecodeArgsMap decodes model-produced built-in tool arguments while preserving
|
||||
// JSON number spellings for lossless numeric coercion by the typed readers below.
|
||||
func DecodeArgsMap(raw []byte) (map[string]any, error) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var result map[string]any
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("invalid JSON arguments: multiple top-level values")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadStringArg reads the first string value matching one of the provided keys.
|
||||
func ReadStringArg(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReadBoolArg reads the first bool value matching one of the provided keys.
|
||||
func ReadBoolArg(args map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if item, ok := value.(bool); ok {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasArgKey reports whether any candidate key is present with a non-null value.
|
||||
func HasArgKey(args map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if ok && value != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoolPtrIfPresent returns a bool pointer only when a matching key is present.
|
||||
func BoolPtrIfPresent(args map[string]any, keys ...string) *bool {
|
||||
if !HasArgKey(args, keys...) {
|
||||
return nil
|
||||
}
|
||||
value := ReadBoolArg(args, keys...)
|
||||
return &value
|
||||
}
|
||||
|
||||
// ReadStringSliceArg reads a string array value matching one of the provided keys.
|
||||
func ReadStringSliceArg(args map[string]any, keys ...string) []string {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if direct, ok := value.([]string); ok {
|
||||
return append([]string(nil), direct...)
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readArgValue(args map[string]any, keys ...string) (any, string, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
return value, key, true
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// ReadIntArg reads an int field from a JSON number or lossless numeric string.
|
||||
func ReadIntArg(args map[string]any, keys ...string) (int, bool, error) {
|
||||
value, key, found := readArgValue(args, keys...)
|
||||
if !found {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := parseIntegerValue(value, key, int64MinForBits(strconv.IntSize), int64MaxForBits(strconv.IntSize))
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return int(parsed), true, nil
|
||||
}
|
||||
|
||||
// ReadInt32Arg reads an int32 field from a JSON number or lossless numeric string.
|
||||
func ReadInt32Arg(args map[string]any, keys ...string) (int32, bool, error) {
|
||||
value, key, found := readArgValue(args, keys...)
|
||||
if !found {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := parseIntegerValue(value, key, math.MinInt32, math.MaxInt32)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return int32(parsed), true, nil
|
||||
}
|
||||
|
||||
// ReadInt64Arg reads an int64 field from a JSON number or lossless numeric string.
|
||||
func ReadInt64Arg(args map[string]any, keys ...string) (int64, bool, error) {
|
||||
value, key, found := readArgValue(args, keys...)
|
||||
if !found {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := parseIntegerValue(value, key, math.MinInt64, math.MaxInt64)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
// ReadUint32Arg reads a uint32 field from a JSON number or lossless numeric string.
|
||||
func ReadUint32Arg(args map[string]any, keys ...string) (uint32, bool, error) {
|
||||
value, key, found := readArgValue(args, keys...)
|
||||
if !found {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := parseUnsignedIntegerValue(value, key, math.MaxUint32)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return uint32(parsed), true, nil
|
||||
}
|
||||
|
||||
// ReadFloat64Arg reads a float64 field from a JSON number or numeric string.
|
||||
func ReadFloat64Arg(args map[string]any, keys ...string) (float64, bool, error) {
|
||||
value, key, found := readArgValue(args, keys...)
|
||||
if !found {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := parseFloatValue(value, key)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func parseIntegerValue(value any, key string, minValue int64, maxValue int64) (int64, error) {
|
||||
switch item := value.(type) {
|
||||
case json.Number:
|
||||
return parseIntegerLiteral(item.String(), key, minValue, maxValue)
|
||||
case string:
|
||||
return parseIntegerLiteral(strings.TrimSpace(item), key, minValue, maxValue)
|
||||
case float64:
|
||||
return parseIntegerFloat(item, key, minValue, maxValue)
|
||||
case float32:
|
||||
return parseIntegerFloat(float64(item), key, minValue, maxValue)
|
||||
default:
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
parsed := reflected.Int()
|
||||
if parsed < minValue || parsed > maxValue {
|
||||
return 0, fmt.Errorf("%s is outside supported integer range", key)
|
||||
}
|
||||
return parsed, nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
parsed := reflected.Uint()
|
||||
if parsed > uint64(maxValue) {
|
||||
return 0, fmt.Errorf("%s is outside supported integer range", key)
|
||||
}
|
||||
return int64(parsed), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseUnsignedIntegerValue(value any, key string, maxValue uint64) (uint64, error) {
|
||||
switch item := value.(type) {
|
||||
case json.Number:
|
||||
return parseUnsignedIntegerLiteral(item.String(), key, maxValue)
|
||||
case string:
|
||||
return parseUnsignedIntegerLiteral(strings.TrimSpace(item), key, maxValue)
|
||||
case float64:
|
||||
return parseUnsignedIntegerFloat(item, key, maxValue)
|
||||
case float32:
|
||||
return parseUnsignedIntegerFloat(float64(item), key, maxValue)
|
||||
default:
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
parsed := reflected.Int()
|
||||
if parsed < 0 {
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
if uint64(parsed) > maxValue {
|
||||
return 0, fmt.Errorf("%s is outside supported unsigned integer range", key)
|
||||
}
|
||||
return uint64(parsed), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
parsed := reflected.Uint()
|
||||
if parsed > maxValue {
|
||||
return 0, fmt.Errorf("%s is outside supported unsigned integer range", key)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntegerLiteral(raw string, key string, minValue int64, maxValue int64) (int64, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
if parsed < minValue || parsed > maxValue {
|
||||
return 0, fmt.Errorf("%s is outside supported integer range", key)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseUnsignedIntegerLiteral(raw string, key string, maxValue uint64) (uint64, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(raw, "-") {
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
if parsed > maxValue {
|
||||
return 0, fmt.Errorf("%s is outside supported unsigned integer range", key)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseIntegerFloat(value float64, key string, minValue int64, maxValue int64) (int64, error) {
|
||||
if !isFiniteFloat(value) || math.Trunc(value) != value {
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
if value < float64(minValue) || value > float64(maxValue) {
|
||||
return 0, fmt.Errorf("%s is outside supported integer range", key)
|
||||
}
|
||||
return int64(value), nil
|
||||
}
|
||||
|
||||
func parseUnsignedIntegerFloat(value float64, key string, maxValue uint64) (uint64, error) {
|
||||
if !isFiniteFloat(value) || math.Trunc(value) != value {
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
if value > float64(maxValue) {
|
||||
return 0, fmt.Errorf("%s is outside supported unsigned integer range", key)
|
||||
}
|
||||
return uint64(value), nil
|
||||
}
|
||||
|
||||
func parseFloatValue(value any, key string) (float64, error) {
|
||||
switch item := value.(type) {
|
||||
case json.Number:
|
||||
parsed, err := item.Float64()
|
||||
if err != nil || !isFiniteFloat(parsed) {
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
return parsed, nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil || !isFiniteFloat(parsed) {
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
return parsed, nil
|
||||
case float64:
|
||||
if !isFiniteFloat(item) {
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
return item, nil
|
||||
case float32:
|
||||
parsed := float64(item)
|
||||
if !isFiniteFloat(parsed) {
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(reflected.Int()), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(reflected.Uint()), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%s must be a finite number", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func int64MinForBits(bits int) int64 {
|
||||
if bits >= 64 {
|
||||
return math.MinInt64
|
||||
}
|
||||
return -(int64(1) << (bits - 1))
|
||||
}
|
||||
|
||||
func int64MaxForBits(bits int) int64 {
|
||||
if bits >= 64 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return (int64(1) << (bits - 1)) - 1
|
||||
}
|
||||
|
||||
func isFiniteFloat(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// types.go 定义运行时、公用命令、事件、状态与 pending 结构。
|
||||
package runtimecore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
// SubagentModelOverrideSelection 表示父 run 对某类 subagent 的模型选择覆盖。
|
||||
type SubagentModelOverrideSelection struct {
|
||||
SubagentType string `json:"subagent_type"`
|
||||
Selection string `json:"selection"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
MaxMode bool `json:"max_mode,omitempty"`
|
||||
ParameterCount int `json:"parameter_count,omitempty"`
|
||||
BuiltInModel bool `json:"built_in_model,omitempty"`
|
||||
IsVariantStringRepresentation bool `json:"is_variant_string_representation,omitempty"`
|
||||
}
|
||||
|
||||
// LookupSubagentModelOverride 按 Task subagent_type 查找运行期模型覆盖。
|
||||
func LookupSubagentModelOverride(overrides map[string]SubagentModelOverrideSelection, subagentType string) (SubagentModelOverrideSelection, string, bool) {
|
||||
if len(overrides) == 0 {
|
||||
return SubagentModelOverrideSelection{}, "", false
|
||||
}
|
||||
for _, key := range subagentModelOverrideLookupKeys(subagentType) {
|
||||
if selection, ok := overrides[key]; ok {
|
||||
return selection, key, true
|
||||
}
|
||||
}
|
||||
return SubagentModelOverrideSelection{}, "", false
|
||||
}
|
||||
|
||||
func subagentModelOverrideLookupKeys(subagentType string) []string {
|
||||
trimmed := strings.TrimSpace(subagentType)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
keys := []string{trimmed}
|
||||
switch trimmed {
|
||||
case "generalPurpose":
|
||||
keys = append(keys, "explore")
|
||||
case "explore":
|
||||
keys = append(keys, "generalPurpose")
|
||||
case "browserUse":
|
||||
keys = append(keys, "browser-use")
|
||||
case "browser-use":
|
||||
keys = append(keys, "browserUse")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
// RunStateIdle 表示空闲态,此时 session 已存在但没有活跃 run。
|
||||
RunStateIdle RunState = "IDLE"
|
||||
// RunStateRestoring 表示恢复态,此时正在装载会话状态与最小恢复信息。
|
||||
RunStateRestoring RunState = "RESTORING"
|
||||
// RunStatePreparingModelInput 表示模型输入准备态。
|
||||
RunStatePreparingModelInput RunState = "PREPARING_MODEL_INPUT"
|
||||
// RunStateStreamingModel 表示模型流消费态。
|
||||
RunStateStreamingModel RunState = "STREAMING_MODEL"
|
||||
// RunStateWaitingExec 表示执行桥等待态。
|
||||
RunStateWaitingExec RunState = "WAITING_EXEC"
|
||||
// RunStateWaitingInteraction 表示交互桥等待态。
|
||||
RunStateWaitingInteraction RunState = "WAITING_INTERACTION"
|
||||
// RunStateApplyingExternalResult 表示外部结果回写态。
|
||||
RunStateApplyingExternalResult RunState = "APPLYING_EXTERNAL_RESULT"
|
||||
// RunStateCheckpointing 表示检查点写入态。
|
||||
RunStateCheckpointing RunState = "CHECKPOINTING"
|
||||
// RunStateCompleted 表示正常完成态。
|
||||
RunStateCompleted RunState = "COMPLETED"
|
||||
// RunStateCanceled 表示取消终态。
|
||||
RunStateCanceled RunState = "CANCELED"
|
||||
// RunStateFailed 表示失败终态。
|
||||
RunStateFailed RunState = "FAILED"
|
||||
)
|
||||
|
||||
// CommandKind 表示运行时接收的上行命令类型。
|
||||
type CommandKind string
|
||||
|
||||
const (
|
||||
// CommandKindRunRequested 表示收到 `run_request`。
|
||||
CommandKindRunRequested CommandKind = "run_requested"
|
||||
// CommandKindPrewarmRequested 表示收到 `prewarm_request`。
|
||||
CommandKindPrewarmRequested CommandKind = "prewarm_requested"
|
||||
// CommandKindCancelRequested 表示收到 `conversation_action.cancel_action`。
|
||||
CommandKindCancelRequested CommandKind = "cancel_requested"
|
||||
// CommandKindConversationActionRecordOnly 表示收到非取消型的 `conversation_action`,当前阶段只记录不推进状态。
|
||||
CommandKindConversationActionRecordOnly CommandKind = "conversation_action_record_only"
|
||||
// CommandKindExecClientMessage 表示收到 `exec_client_message`。
|
||||
CommandKindExecClientMessage CommandKind = "exec_client_message"
|
||||
// CommandKindInteractionResponse 表示收到 `interaction_response`。
|
||||
CommandKindInteractionResponse CommandKind = "interaction_response"
|
||||
// CommandKindExecClientControlMessage 表示收到 `exec_client_control_message`,当前阶段只记录不推进状态。
|
||||
CommandKindExecClientControlMessage CommandKind = "exec_client_control_message"
|
||||
// CommandKindClientHeartbeat 表示收到客户端心跳,当前阶段只记录不推进状态。
|
||||
CommandKindClientHeartbeat CommandKind = "client_heartbeat"
|
||||
// CommandKindKVClientMessage 表示收到 `kv_client_message`,当前阶段只记录不推进状态。
|
||||
CommandKindKVClientMessage CommandKind = "kv_client_message"
|
||||
)
|
||||
|
||||
// Command 描述一次投递到运行时协调层的上行命令。
|
||||
type Command struct {
|
||||
// Kind 指定该命令的运行时语义。
|
||||
Kind CommandKind
|
||||
// IsResume 标记当前命令是否为恢复型启动。
|
||||
IsResume bool
|
||||
// ClientKind 保留协议层顶级消息种类,便于观测与调试。
|
||||
ClientKind string
|
||||
// HistoryEntry 保存协议摘要文本,供当前 MVP 的合成回复使用。
|
||||
HistoryEntry string
|
||||
// ClientMessage 保存解码后的完整上行协议消息。
|
||||
ClientMessage *agentv1.AgentClientMessage
|
||||
}
|
||||
|
||||
// EventKind 表示一次可回放下行事件的业务类型。
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
// EventKindRunStarted 表示新 run 已创建并开始进入恢复路径。
|
||||
EventKindRunStarted EventKind = "run_started"
|
||||
// EventKindStepStarted 表示步骤开始事件。
|
||||
EventKindStepStarted EventKind = "step_started"
|
||||
// EventKindTextDelta 表示文本增量事件。
|
||||
EventKindTextDelta EventKind = "text_delta"
|
||||
// EventKindStepCompleted 表示步骤完成事件。
|
||||
EventKindStepCompleted EventKind = "step_completed"
|
||||
// EventKindTurnEnded 表示回合结束事件。
|
||||
EventKindTurnEnded EventKind = "turn_ended"
|
||||
// EventKindCheckpoint 表示会话检查点事件。
|
||||
EventKindCheckpoint EventKind = "checkpoint"
|
||||
// EventKindCanceled 表示取消事件。
|
||||
EventKindCanceled EventKind = "canceled"
|
||||
// EventKindHeartbeat 表示服务端心跳事件。
|
||||
EventKindHeartbeat EventKind = "heartbeat"
|
||||
)
|
||||
|
||||
// Event 表示一条可广播、可回放的下行事件记录。
|
||||
type Event struct {
|
||||
// Seq 是请求维度内递增的事件序号。
|
||||
Seq int64
|
||||
// RequestID 是事件所属请求标识。
|
||||
RequestID string
|
||||
// RunID 是事件所属运行标识。
|
||||
RunID string
|
||||
// Kind 标识该事件的业务类型。
|
||||
Kind EventKind
|
||||
// Message 是要透传到 RunSSE 的协议消息体。
|
||||
Message *agentv1.AgentServerMessage
|
||||
// End 表示该事件会结束当前 SSE 读取。
|
||||
End bool
|
||||
// TerminalErrorCode 表示当前终态 SSE 需要返回的 connect error code,例如 canceled。
|
||||
TerminalErrorCode string
|
||||
// TerminalErrorMessage 表示当前终态 SSE 需要返回的错误消息。
|
||||
TerminalErrorMessage string
|
||||
// CreatedAt 是事件入库时间。
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RunSnapshot 表示一次 run 的最小快照信息。
|
||||
type RunSnapshot struct {
|
||||
// RunID 是运行唯一标识。
|
||||
RunID string
|
||||
// RequestID 是当前 run 绑定的请求标识。
|
||||
RequestID string
|
||||
// ConversationID 是当前 run 绑定的会话标识。
|
||||
ConversationID string
|
||||
// ModelID 表示当前运行使用的模型标识。
|
||||
ModelID string
|
||||
// State 表示该 run 当前所处状态。
|
||||
State RunState
|
||||
// Mode 表示该 run 当前使用的会话模式。
|
||||
Mode agentv1.AgentMode
|
||||
// Version 是运行时版本号,便于后续扩展乐观更新。
|
||||
Version int64
|
||||
// StartedAt 记录 run 启动时间。
|
||||
StartedAt time.Time
|
||||
// UpdatedAt 记录 run 最近一次状态更新时间。
|
||||
UpdatedAt time.Time
|
||||
// CurrentUserMessageText 保存当前 turn 的用户输入文本,直到本 turn 提交进 `turns`。
|
||||
CurrentUserMessageText string
|
||||
// CustomSystemPrompt 保存当前 run 附带的自定义系统提示词。
|
||||
CustomSystemPrompt string
|
||||
// RequestContextPayload 保存当前 run 的 request_context proto 序列化结果。
|
||||
RequestContextPayload []byte
|
||||
// IsPrewarm 标记当前 run 是否由 `prewarm_request` 触发。
|
||||
IsPrewarm bool
|
||||
}
|
||||
|
||||
// PendingAssistantOutput 表示尚未收口的一条 assistant 输出记录。
|
||||
type PendingAssistantOutput struct {
|
||||
// RawMessage 保存原始序列化 assistant message。
|
||||
RawMessage string
|
||||
// Role 表示该记录的 role,当前常见值为 assistant。
|
||||
Role string
|
||||
// ContentKinds 记录内容块类型顺序,例如 text 或 tool-call。
|
||||
ContentKinds []string
|
||||
// ToolCallIDs 记录该输出中出现的全部 tool_call_id。
|
||||
ToolCallIDs []string
|
||||
// ToolNames 记录该输出中出现的全部工具名称。
|
||||
ToolNames []string
|
||||
// TextPreview 保存文本块的简要摘要。
|
||||
TextPreview string
|
||||
}
|
||||
|
||||
// PendingExec 表示一条尚未收口的执行桥记录。
|
||||
type PendingExec struct {
|
||||
// MessageID 是打开该执行桥时下发给客户端的桥消息编号。
|
||||
MessageID uint32
|
||||
// ExecID 是执行桥唯一标识。
|
||||
ExecID string
|
||||
// ProviderPass 表示创建该执行桥时所属的 provider pass。
|
||||
ProviderPass int
|
||||
// ModelCallID 是触发该执行桥的模型调用标识。
|
||||
ModelCallID string
|
||||
// ToolCallID 是与该执行桥关联的工具调用标识。
|
||||
ToolCallID string
|
||||
// ArgsJSON 保存打开该执行桥时的原始参数 JSON,便于恢复 completed ToolCall。
|
||||
ArgsJSON []byte
|
||||
// ReasoningContent 保存触发该工具调用时的 thinking 文本,供 checkpoint/replay 续跑复用。
|
||||
ReasoningContent string
|
||||
// ReasoningSignature 保存 provider 对当前 thinking 文本签发的签名。
|
||||
ReasoningSignature string
|
||||
// ReasoningSignatureSource 保存 reasoning signature 的 provider 语义来源。
|
||||
ReasoningSignatureSource string
|
||||
// ExecKind 描述执行桥类型,例如 read、write、shellStream。
|
||||
ExecKind string
|
||||
// StreamState 描述当前流式执行桥的阶段。
|
||||
StreamState string
|
||||
// OpenedAt 表示执行桥请求发出的时间。
|
||||
OpenedAt time.Time
|
||||
// FirstChunkAt 表示 shellStream 首个输出块时间。
|
||||
FirstChunkAt time.Time
|
||||
// ChunkCount 表示 shellStream 已接收的输出块数量。
|
||||
ChunkCount int64
|
||||
// LastShellActivityAt 记录最近一次 shell 相关上行事件时间,包括输出、start、heartbeat 和 close。
|
||||
LastShellActivityAt time.Time
|
||||
// LastShellHeartbeatAt 记录最近一次 shell heartbeat 到达时间。
|
||||
LastShellHeartbeatAt time.Time
|
||||
// ShellForegroundDeadline 表示前台 shell 预计最晚应收到终态的时间点。
|
||||
ShellForegroundDeadline time.Time
|
||||
// ShellRecoveryScheduled 标记是否已经为该 shell 安排了异常收口协程。
|
||||
ShellRecoveryScheduled bool
|
||||
// StdoutBuffer 保存当前 shell 已累计的 stdout 文本。
|
||||
StdoutBuffer string
|
||||
// StderrBuffer 保存当前 shell 已累计的 stderr 文本。
|
||||
StderrBuffer string
|
||||
// ArtifactPath 保存该 exec 对应的原始桥接工件路径。
|
||||
ArtifactPath string
|
||||
}
|
||||
|
||||
// PendingInteraction 表示一条尚未收口的交互桥记录。
|
||||
type PendingInteraction struct {
|
||||
// InteractionID 是交互桥唯一标识。
|
||||
InteractionID string
|
||||
// ProviderPass 表示创建该交互桥时所属的 provider pass。
|
||||
ProviderPass int
|
||||
// ModelCallID 是触发该交互桥的模型调用标识。
|
||||
ModelCallID string
|
||||
// ToolCallID 是与该交互桥关联的工具调用标识。
|
||||
ToolCallID string
|
||||
// ArgsJSON 保存打开该交互桥时的原始参数 JSON,便于结果回写时恢复结构化状态。
|
||||
ArgsJSON []byte
|
||||
// ReasoningContent 保存触发该工具调用时的 thinking 文本,供 checkpoint/replay 续跑复用。
|
||||
ReasoningContent string
|
||||
// ReasoningSignature 保存 provider 对当前 thinking 文本签发的签名。
|
||||
ReasoningSignature string
|
||||
// ReasoningSignatureSource 保存 reasoning signature 的 provider 语义来源。
|
||||
ReasoningSignatureSource string
|
||||
// InteractionKind 描述交互类型,例如 ask_question、create_plan。
|
||||
InteractionKind string
|
||||
// OpenedAt 表示交互请求发出的时间。
|
||||
OpenedAt time.Time
|
||||
// ArtifactPath 保存该 interaction 对应的原始桥接工件路径。
|
||||
ArtifactPath string
|
||||
}
|
||||
|
||||
// ActiveStep 表示当前正在推进、尚未收口的 step 元数据。
|
||||
type ActiveStep struct {
|
||||
// StepID 是当前 step 唯一标识。
|
||||
StepID uint64
|
||||
// ModelCallID 是当前 step 绑定的模型调用标识。
|
||||
ModelCallID string
|
||||
// StartedAt 是当前 step 的开始时间。
|
||||
StartedAt time.Time
|
||||
// InputTokens 保存当前 step 已知的输入 token 数。
|
||||
InputTokens int64
|
||||
// OutputTokens 保存当前 step 已知的输出 token 数。
|
||||
OutputTokens int64
|
||||
}
|
||||
|
||||
// ExternalResultSummary 表示 APPLYING_EXTERNAL_RESULT 后继续下一轮编译所需的最小上下文。
|
||||
type ExternalResultSummary struct {
|
||||
// Source 表示结果来源,例如 exec 或 interaction。
|
||||
Source string
|
||||
// ToolName 表示对应工具名或交互名。
|
||||
ToolName string
|
||||
// Payload 表示可直接注入 prompt 的结果摘要。
|
||||
Payload string
|
||||
}
|
||||
|
||||
// ToolInvocation 表示一次模型产出的工具调用意图。
|
||||
type ToolInvocation struct {
|
||||
// CallID 是模型层工具调用标识。
|
||||
CallID string
|
||||
// ToolName 表示工具名称,例如 Read、Write、AskQuestion。
|
||||
ToolName string
|
||||
// ArgsJSON 保存工具参数原始 JSON。
|
||||
ArgsJSON []byte
|
||||
// ReasoningContent 保存当前工具调用前伴随的 thinking 文本。
|
||||
ReasoningContent string
|
||||
// ReasoningSignature 保存 provider 对当前 thinking 文本签发的签名。
|
||||
ReasoningSignature string
|
||||
// ReasoningSignatureSource 保存 reasoning signature 的 provider 语义来源。
|
||||
ReasoningSignatureSource string
|
||||
// ReasoningProviderItemID 保存 provider 原始 reasoning output item id。
|
||||
ReasoningProviderItemID string
|
||||
// ReasoningProviderStatus 保存 provider 原始 reasoning output item status。
|
||||
ReasoningProviderStatus string
|
||||
// ReasoningProviderSummary 保存 provider 原始 reasoning output item summary。
|
||||
ReasoningProviderSummary json.RawMessage
|
||||
// ProviderItemID 保存 provider 原始 tool/function output item id。
|
||||
ProviderItemID string
|
||||
// ProviderCallID 保存 provider 原始 tool/function call id。
|
||||
ProviderCallID string
|
||||
// ProviderStatus 保存 provider 原始 tool/function output item status。
|
||||
ProviderStatus string
|
||||
// ModelCallID 表示本轮模型调用标识。
|
||||
ModelCallID string
|
||||
}
|
||||
|
||||
// NormalizeSupportedMode 规范化并校验当前支持的会话 mode。
|
||||
//
|
||||
// 当前默认口径:
|
||||
// 1. 未显式携带 mode 或值为 `AGENT_MODE_UNSPECIFIED` 时,按 `AGENT_MODE_AGENT` 处理;
|
||||
// 2. 仅允许 `AGENT_MODE_AGENT`、`AGENT_MODE_ASK`、`AGENT_MODE_PLAN`、`AGENT_MODE_DEBUG`、`AGENT_MODE_MULTITASK`;
|
||||
// 3. 其他 mode 一律报错,不允许静默回退。
|
||||
func NormalizeSupportedMode(mode agentv1.AgentMode) (agentv1.AgentMode, error) {
|
||||
switch mode {
|
||||
case agentv1.AgentMode_AGENT_MODE_UNSPECIFIED:
|
||||
return agentv1.AgentMode_AGENT_MODE_AGENT, nil
|
||||
case agentv1.AgentMode_AGENT_MODE_AGENT,
|
||||
agentv1.AgentMode_AGENT_MODE_ASK,
|
||||
agentv1.AgentMode_AGENT_MODE_PLAN,
|
||||
agentv1.AgentMode_AGENT_MODE_DEBUG,
|
||||
agentv1.AgentMode_AGENT_MODE_MULTITASK:
|
||||
return mode, nil
|
||||
default:
|
||||
return agentv1.AgentMode_AGENT_MODE_UNSPECIFIED, fmt.Errorf("unsupported mode: %s", mode.String())
|
||||
}
|
||||
}
|
||||
|
||||
// CloneToolCallMap 深拷贝 tool_call 结果映射,避免共享 proto 指针。
|
||||
func CloneToolCallMap(items map[string]*agentv1.ToolCall) map[string]*agentv1.ToolCall {
|
||||
if len(items) == 0 {
|
||||
return make(map[string]*agentv1.ToolCall)
|
||||
}
|
||||
|
||||
cloned := make(map[string]*agentv1.ToolCall, len(items))
|
||||
for key, value := range items {
|
||||
if value == nil {
|
||||
cloned[key] = nil
|
||||
continue
|
||||
}
|
||||
typed, ok := proto.Clone(value).(*agentv1.ToolCall)
|
||||
if !ok {
|
||||
cloned[key] = nil
|
||||
continue
|
||||
}
|
||||
cloned[key] = typed
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// IsCurrentlySupportedTool 判断当前 Phase 5 稳定化版本是否真正支持该工具。
|
||||
//
|
||||
// 当前规则:
|
||||
// 1. 只返回 runtime/loop 当前已经具备完整推进链路的能力;
|
||||
// 2. 结果用于限制实际对模型暴露的工具集合,避免模型调用未实现能力后把整轮 run 直接打失败;
|
||||
// 3. 必须保持最小闭环优先,而不是优先暴露抓包里存在但服务端尚未支持的能力。
|
||||
func IsCurrentlySupportedTool(name string) bool {
|
||||
switch strings.TrimSpace(name) {
|
||||
case "Read", "Write", "PatchEdit", "Delete", "Shell", "AwaitShell", "WriteShellStdin", "ForceBackgroundShell",
|
||||
"Glob", "Grep", "ReadLints",
|
||||
"AskQuestion", "CreatePlan", "SwitchMode", "WebSearch", "WebFetch",
|
||||
"TodoWrite", "Task",
|
||||
"CallMcpTool", "FetchMcpResource":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnthropicMessageCacheBreakpointsPreserveAppendOnlyHistory(t *testing.T) {
|
||||
for size := 1; size <= 32; size++ {
|
||||
t.Run(fmt.Sprintf("size_%02d", size), func(t *testing.T) {
|
||||
previous := anthropicMessagesForAppendOnlyTest(size)
|
||||
current := anthropicMessagesForAppendOnlyTest(size + 1)
|
||||
|
||||
applyAnthropicMessageCacheBreakpoints(previous)
|
||||
applyAnthropicMessageCacheBreakpoints(current)
|
||||
|
||||
want := mustMarshalAnthropicMessagesForTest(t, previous)
|
||||
got := mustMarshalAnthropicMessagesForTest(t, current[:len(previous)])
|
||||
if got != want {
|
||||
t.Fatalf("historical message prefix changed after append\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicMessagesForAppendOnlyTest(count int) []anthropicMessage {
|
||||
messages := make([]anthropicMessage, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: "user",
|
||||
Content: []map[string]any{{
|
||||
"type": "text",
|
||||
"text": fmt.Sprintf("message-%02d", index),
|
||||
}},
|
||||
})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func mustMarshalAnthropicMessagesForTest(t *testing.T, messages []anthropicMessage) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal anthropic messages: %v", err)
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recordLLMRequestArtifact 记录一次模型调用的原始请求工件。
|
||||
func recordLLMRequestArtifact(req StreamRequest, provider string, model string, method string, url string, body any) {
|
||||
if req.Observer == nil {
|
||||
return
|
||||
}
|
||||
path, err := req.Observer.RecordLLMRequest(req.RequestID, req.RunID, req.ModelCallID, map[string]any{
|
||||
"request_id": req.RequestID,
|
||||
"run_id": req.RunID,
|
||||
"model_call_id": req.ModelCallID,
|
||||
"provider": strings.TrimSpace(provider),
|
||||
"openai_endpoint": strings.TrimSpace(req.OpenAIEndpoint),
|
||||
"model": firstNonEmptyString(model, req.ModelID),
|
||||
"runtime_model_id": strings.TrimSpace(req.ModelID),
|
||||
"resolved_channel_id": strings.TrimSpace(req.ResolvedChannelID),
|
||||
"resolved_channel_name": strings.TrimSpace(req.ResolvedChannelName),
|
||||
"resolved_context_window_tokens": req.ResolvedContextWindowTokens,
|
||||
"url": url,
|
||||
"method": method,
|
||||
"body": body,
|
||||
"request_knobs": req.RequestKnobs,
|
||||
"compile_summary": req.CompileSummary,
|
||||
"stable_message_count": req.StableMessageCount,
|
||||
"tools_summary": summarizeTools(req.Tools),
|
||||
"messages_summary": summarizeMessages(req.Messages),
|
||||
})
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.RequestPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// buildLLMSummaryPayload 生成 LLM 调用摘要工件内容。
|
||||
func buildLLMSummaryPayload(
|
||||
req StreamRequest,
|
||||
provider string,
|
||||
model string,
|
||||
startedAt time.Time,
|
||||
firstEventAt time.Time,
|
||||
finishedAt time.Time,
|
||||
finishReason string,
|
||||
inputTokens int64,
|
||||
outputTokens int64,
|
||||
cacheReadTokens int64,
|
||||
cacheWriteTokens int64,
|
||||
err error,
|
||||
) map[string]any {
|
||||
finished := finishedAt
|
||||
if finished.IsZero() {
|
||||
finished = time.Now().UTC()
|
||||
}
|
||||
promptTokensTotal := inputTokens + cacheReadTokens + cacheWriteTokens
|
||||
requestTokensTotal := promptTokensTotal + outputTokens
|
||||
return map[string]any{
|
||||
"provider": strings.TrimSpace(provider),
|
||||
"model": strings.TrimSpace(model),
|
||||
"started_at": normalizeModelArtifactTime(startedAt),
|
||||
"first_event_at": normalizeModelArtifactTime(firstEventAt),
|
||||
"finished_at": normalizeModelArtifactTime(finished),
|
||||
"finish_reason": strings.TrimSpace(finishReason),
|
||||
"input_tokens": inputTokens,
|
||||
"output_tokens": outputTokens,
|
||||
"cache_read_tokens": cacheReadTokens,
|
||||
"cache_write_tokens": cacheWriteTokens,
|
||||
"prompt_tokens_total": promptTokensTotal,
|
||||
"request_tokens_total": requestTokensTotal,
|
||||
"error": summarizeModelArtifactError(err),
|
||||
"ttft_ms": computeTTFTMS(startedAt, firstEventAt),
|
||||
"duration_ms": computeDurationMS(startedAt, finished),
|
||||
}
|
||||
}
|
||||
|
||||
// appendLLMResponseArtifact 追加模型调用的原始响应文本。
|
||||
func appendLLMResponseArtifact(req StreamRequest, chunk string) (string, error) {
|
||||
if req.Observer == nil {
|
||||
return "", nil
|
||||
}
|
||||
path, err := req.Observer.AppendLLMResponseChunk(req.RequestID, req.RunID, req.ModelCallID, chunk)
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.ResponsePath = path
|
||||
}
|
||||
return path, err
|
||||
}
|
||||
|
||||
// recordLLMSummaryArtifact 记录模型调用摘要。
|
||||
func recordLLMSummaryArtifact(req StreamRequest, payload map[string]any) {
|
||||
if req.Observer == nil {
|
||||
return
|
||||
}
|
||||
path, err := req.Observer.RecordLLMSummary(req.RequestID, req.RunID, req.ModelCallID, payload)
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.SummaryPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeTools 生成工具列表摘要。
|
||||
func summarizeTools(items []json.RawMessage) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
var wrapper struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
}
|
||||
if err := json.Unmarshal(item, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(wrapper.Function.Name) != "" {
|
||||
result = append(result, strings.TrimSpace(wrapper.Function.Name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// summarizeMessages 生成消息列表摘要。
|
||||
func summarizeMessages(items []Message) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
content := truncateArtifactText(item.Content, 120)
|
||||
imageCount := 0
|
||||
for _, part := range item.ContentParts {
|
||||
if normalizeContentPartType(part.Type) == contentPartTypeImage {
|
||||
imageCount++
|
||||
}
|
||||
}
|
||||
result = append(result, map[string]any{
|
||||
"role": item.Role,
|
||||
"content_preview": content,
|
||||
"content_length": len([]rune(content)),
|
||||
"image_count": imageCount,
|
||||
"tool_call_count": len(item.ToolCalls),
|
||||
"tool_call_id": item.ToolCallID,
|
||||
"name": item.Name,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateArtifactText(text string, maxRunes int) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" || maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= maxRunes {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
// normalizeModelArtifactTime 把时间格式化为 RFC3339Nano。
|
||||
func normalizeModelArtifactTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// computeTTFTMS 计算首事件耗时。
|
||||
func computeTTFTMS(startedAt time.Time, firstEventAt time.Time) int64 {
|
||||
if startedAt.IsZero() || firstEventAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
value := firstEventAt.Sub(startedAt).Milliseconds()
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// computeDurationMS 计算调用总耗时。
|
||||
func computeDurationMS(startedAt time.Time, finishedAt time.Time) int64 {
|
||||
if startedAt.IsZero() || finishedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
value := finishedAt.Sub(startedAt).Milliseconds()
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// summarizeModelArtifactError 返回可安全落盘的错误文本。
|
||||
func summarizeModelArtifactError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(err.Error())
|
||||
}
|
||||
|
||||
// firstNonEmptyString 返回第一个非空字符串。
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
contentPartTypeText = "text"
|
||||
contentPartTypeImage = "image"
|
||||
)
|
||||
|
||||
// ContentPart 表示一条消息中的结构化内容块。
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Image *ImageContent `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// ImageContent 表示消息中携带的一张图片。
|
||||
type ImageContent struct {
|
||||
MIMEType string `json:"mime_type,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func hasImageContentParts(parts []ContentPart) bool {
|
||||
for _, part := range parts {
|
||||
if normalizeContentPartType(part.Type) == contentPartTypeImage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collapseTextContentParts(parts []ContentPart) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
texts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if normalizeContentPartType(part.Type) != contentPartTypeText {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(part.Text) == "" {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
return strings.Join(texts, "")
|
||||
}
|
||||
|
||||
func normalizeContentPartType(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
switch trimmed {
|
||||
case "", contentPartTypeText:
|
||||
return contentPartTypeText
|
||||
case contentPartTypeImage:
|
||||
return contentPartTypeImage
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func openAIContentValue(message Message) (any, error) {
|
||||
if !hasImageContentParts(message.ContentParts) {
|
||||
content := message.Content
|
||||
if strings.TrimSpace(content) == "" && len(message.ContentParts) > 0 {
|
||||
content = collapseTextContentParts(message.ContentParts)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
parts := make([]map[string]any, 0, len(message.ContentParts)+1)
|
||||
if len(message.ContentParts) == 0 && strings.TrimSpace(message.Content) != "" {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
})
|
||||
}
|
||||
for _, part := range message.ContentParts {
|
||||
switch normalizeContentPartType(part.Type) {
|
||||
case contentPartTypeText:
|
||||
if part.Text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": part.Text,
|
||||
})
|
||||
case contentPartTypeImage:
|
||||
dataURL, err := imageContentDataURL(part.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": dataURL,
|
||||
},
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported openai content part type: %s", strings.TrimSpace(part.Type))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return message.Content, nil
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func anthropicContentBlocks(message Message) ([]map[string]any, error) {
|
||||
if len(message.ContentParts) == 0 {
|
||||
if strings.TrimSpace(message.Content) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []map[string]any{{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
blocks := make([]map[string]any, 0, len(message.ContentParts))
|
||||
for _, part := range message.ContentParts {
|
||||
switch normalizeContentPartType(part.Type) {
|
||||
case contentPartTypeText:
|
||||
if part.Text == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": part.Text,
|
||||
})
|
||||
case contentPartTypeImage:
|
||||
payload, mediaType, err := resolveImageContent(part.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeImage,
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": mediaType,
|
||||
"data": base64.StdEncoding.EncodeToString(payload),
|
||||
},
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported anthropic content part type: %s", strings.TrimSpace(part.Type))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && strings.TrimSpace(message.Content) != "" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
})
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func imageContentDataURL(image *ImageContent) (string, error) {
|
||||
payload, mediaType, err := resolveImageContent(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func resolveImageContent(image *ImageContent) ([]byte, string, error) {
|
||||
if image == nil {
|
||||
return nil, "", fmt.Errorf("image content is required")
|
||||
}
|
||||
if len(image.Data) > 0 {
|
||||
return image.Data, normalizeImageMIMEType(image.MIMEType, image.Path, image.Data), nil
|
||||
}
|
||||
path := strings.TrimSpace(image.Path)
|
||||
if path == "" {
|
||||
return nil, "", fmt.Errorf("image content is missing data and path")
|
||||
}
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read image content failed: %w", err)
|
||||
}
|
||||
return payload, normalizeImageMIMEType(image.MIMEType, path, payload), nil
|
||||
}
|
||||
|
||||
func normalizeImageMIMEType(mimeType string, path string, payload []byte) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(mimeType))
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
detected := strings.TrimSpace(strings.ToLower(http.DetectContentType(payload)))
|
||||
if strings.HasPrefix(detected, "image/") {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(path))) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
func emitCreatePlanToolProgress(
|
||||
sink func(ModelEvent) error,
|
||||
provider string,
|
||||
model string,
|
||||
callID string,
|
||||
rawArgs string,
|
||||
argsTextDelta string,
|
||||
lastSnapshot *string,
|
||||
) error {
|
||||
if sink == nil || lastSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
trimmedCallID := strings.TrimSpace(callID)
|
||||
if trimmedCallID == "" {
|
||||
return nil
|
||||
}
|
||||
args, ok := createPlanArgsProgressSnapshot(rawArgs)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
signatureBytes, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature := string(signatureBytes)
|
||||
if signature == "" || signature == *lastSnapshot {
|
||||
return nil
|
||||
}
|
||||
*lastSnapshot = signature
|
||||
if err := sink(ModelEvent{
|
||||
Kind: ModelEventKindPartialToolCall,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
ToolCallID: trimmedCallID,
|
||||
ArgsTextDelta: argsTextDelta,
|
||||
ToolCall: &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_CreatePlanToolCall{
|
||||
CreatePlanToolCall: &agentv1.CreatePlanToolCall{
|
||||
Args: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createPlanArgsProgressSnapshot(rawArgs string) (*agentv1.CreatePlanArgs, bool) {
|
||||
trimmed := strings.TrimSpace(rawArgs)
|
||||
if trimmed == "" {
|
||||
return nil, false
|
||||
}
|
||||
if args, err := runtimecore.DecodeCreatePlanArgsJSON([]byte(trimmed)); err == nil && hasCreatePlanArgsProgress(args) {
|
||||
return args, true
|
||||
}
|
||||
|
||||
args := &agentv1.CreatePlanArgs{}
|
||||
if value, found, _ := extractJSONStringFieldPrefix(trimmed, "plan"); found {
|
||||
args.Plan = value
|
||||
}
|
||||
if value, found, _ := extractJSONStringFieldPrefix(trimmed, "overview"); found {
|
||||
args.Overview = value
|
||||
}
|
||||
if value, found, complete := extractJSONStringFieldPrefix(trimmed, "name"); found && complete {
|
||||
args.Name = strings.TrimSpace(value)
|
||||
}
|
||||
if !hasCreatePlanArgsProgress(args) {
|
||||
return nil, false
|
||||
}
|
||||
return args, true
|
||||
}
|
||||
|
||||
func hasCreatePlanArgsProgress(args *agentv1.CreatePlanArgs) bool {
|
||||
if args == nil {
|
||||
return false
|
||||
}
|
||||
return args.GetPlan() != "" ||
|
||||
args.GetOverview() != "" ||
|
||||
args.GetName() != "" ||
|
||||
args.GetIsProject() ||
|
||||
len(args.GetTodos()) > 0 ||
|
||||
len(args.GetPhases()) > 0
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package modeladapter 提供 OpenAI / Anthropic 兼容流式模型适配器与路由器。
|
||||
package modeladapter
|
||||
@@ -0,0 +1,41 @@
|
||||
// http_error.go 负责把非 2xx HTTP 响应整理成带响应体摘要的错误。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxErrorBodyBytes 表示错误响应体最多读取的字节数。
|
||||
maxErrorBodyBytes = 8192
|
||||
)
|
||||
|
||||
// buildHTTPStatusError 读取响应体摘要并生成带状态码的错误。
|
||||
func buildHTTPStatusError(prefix string, resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("%s response is nil", strings.TrimSpace(prefix))
|
||||
}
|
||||
|
||||
limitedBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
||||
if err != nil {
|
||||
if retrySummary := ProviderRetryAttemptSummary(resp); retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, err)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, err)
|
||||
}
|
||||
retrySummary := ProviderRetryAttemptSummary(resp)
|
||||
bodyText := strings.TrimSpace(string(limitedBody))
|
||||
if bodyText == "" {
|
||||
if retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d", strings.TrimSpace(prefix), resp.StatusCode)
|
||||
}
|
||||
if retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s body=%s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, bodyText)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d body=%s", strings.TrimSpace(prefix), resp.StatusCode, bodyText)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
package modeladapter
|
||||
|
||||
func maxAnthropicTokens(req StreamRequest) int {
|
||||
if req.AnthropicMaxTokens > 0 {
|
||||
return req.AnthropicMaxTokens
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
return req.MaxTokens
|
||||
}
|
||||
return 65536
|
||||
}
|
||||
|
||||
func maxThinkingBudget(req StreamRequest) int {
|
||||
if req.ThinkingBudgetTokens > 0 {
|
||||
return req.ThinkingBudgetTokens
|
||||
}
|
||||
return anthropicThinkingBudget(maxAnthropicTokens(req))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cloneRequestBodyOverride(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cloned map[string]any
|
||||
if err := json.Unmarshal(payload, &cloned); err != nil {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func requestBodyToMap(input any) (map[string]any, error) {
|
||||
if body, ok := input.(map[string]any); ok {
|
||||
return body, nil
|
||||
}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(payload, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func ApplyOpenAIExtraParams(body map[string]any, enabled bool, paramsJSON string) error {
|
||||
return applyExtraParams(body, enabled, paramsJSON, "openai extra params json")
|
||||
}
|
||||
|
||||
func ApplyAnthropicExtraParams(body map[string]any, enabled bool, paramsJSON string) error {
|
||||
return applyExtraParams(body, enabled, paramsJSON, "anthropic extra params json")
|
||||
}
|
||||
|
||||
func applyExtraParams(body map[string]any, enabled bool, paramsJSON string, label string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if body == nil {
|
||||
return fmt.Errorf("%s target body is nil", label)
|
||||
}
|
||||
extraParams, err := parseJSONMap(paramsJSON, label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range extraParams {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
body[name] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyCustomHeaders(httpReq *http.Request, enabled bool, headersJSON string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if httpReq == nil {
|
||||
return fmt.Errorf("custom headers target request is nil")
|
||||
}
|
||||
headers, err := parseStringJSONMap(headersJSON, "custom headers json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range headers {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set(name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONMap(value string, label string) (map[string]any, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%s is empty", label)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an object: %w", label, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseStringJSONMap(value string, label string) (map[string]string, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%s is empty", label)
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an object with string values: %w", label, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// retry.go 保留 provider HTTP 请求入口的历史命名;provider 错误交给客户端重连链路处理。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DoProviderRequestWithRetry 保留旧入口名;本地模式不在服务端重试 provider 请求。
|
||||
func DoProviderRequestWithRetry(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
provider string,
|
||||
requestID string,
|
||||
modelCallID string,
|
||||
buildRequest func(context.Context) (*http.Request, error),
|
||||
) (*http.Response, error) {
|
||||
return doProviderRequestWithRetry(ctx, client, provider, requestID, modelCallID, buildRequest)
|
||||
}
|
||||
|
||||
func doProviderRequestWithRetry(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
provider string,
|
||||
requestID string,
|
||||
modelCallID string,
|
||||
buildRequest func(context.Context) (*http.Request, error),
|
||||
) (*http.Response, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
httpReq, err := buildRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ProviderRetryAttemptSummary 返回空值;provider 请求不再有服务端内部重试摘要。
|
||||
func ProviderRetryAttemptSummary(resp *http.Response) string {
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// router.go 按模型标识选择 OpenAI 或 Anthropic 兼容适配器。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
)
|
||||
|
||||
// Router 是 MVP 阶段的模型适配路由器。
|
||||
type Router struct {
|
||||
// openai 负责 OpenAI 兼容流式请求。
|
||||
openai ModelAdapter
|
||||
// anthropic 负责 Anthropic 兼容流式请求。
|
||||
anthropic ModelAdapter
|
||||
// resolver 负责从本地配置中解析实际模型通道。
|
||||
resolver ChannelResolver
|
||||
}
|
||||
|
||||
type ChannelResolver interface {
|
||||
SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error)
|
||||
ProviderStreamIdleTimeout(context.Context) time.Duration
|
||||
}
|
||||
|
||||
// NewRouter 创建模型适配路由器。
|
||||
func NewRouter(resolver ChannelResolver) *Router {
|
||||
return &Router{
|
||||
openai: NewOpenAIAdapter(),
|
||||
anthropic: NewAnthropicAdapter(),
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// Stream 根据模型标识选择具体 provider 并转发请求。
|
||||
func (router *Router) Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error {
|
||||
if router == nil || router.resolver == nil {
|
||||
return fmt.Errorf("model adapter resolver is unavailable")
|
||||
}
|
||||
channel, err := router.resolver.SelectChannelForModel(ctx, req.ModelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no available channel for model %q", req.ModelID)
|
||||
}
|
||||
|
||||
resolved := req
|
||||
resolved.Provider = strings.TrimSpace(channel.Provider)
|
||||
resolved.BaseURL = strings.TrimSpace(channel.BaseURL)
|
||||
resolved.APIKey = strings.TrimSpace(channel.APIKey)
|
||||
resolved.ProviderModelID = strings.TrimSpace(channel.Model)
|
||||
resolved.ResolvedChannelID = strings.TrimSpace(channel.ID)
|
||||
resolved.ResolvedChannelName = strings.TrimSpace(channel.Name)
|
||||
resolved.ResolvedContextWindowTokens = channel.ContextWindowTokens
|
||||
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(channel.ReasoningEffort)
|
||||
resolved.OpenAIEndpoint = strings.TrimSpace(channel.OpenAIEndpoint)
|
||||
resolved.OpenAIExtraParamsEnabled = channel.OpenAIExtraParamsEnabled
|
||||
resolved.OpenAIExtraParamsJSON = strings.TrimSpace(channel.OpenAIExtraParamsJSON)
|
||||
resolved.CustomHeadersEnabled = channel.CustomHeadersEnabled
|
||||
resolved.CustomHeadersJSON = strings.TrimSpace(channel.CustomHeadersJSON)
|
||||
resolved.AnthropicExtraParamsEnabled = channel.AnthropicExtraParamsEnabled
|
||||
resolved.AnthropicExtraParamsJSON = strings.TrimSpace(channel.AnthropicExtraParamsJSON)
|
||||
resolved.AnthropicMaxTokens = channel.AnthropicMaxTokens
|
||||
resolved.AnthropicThinkingEffort = strings.TrimSpace(channel.AnthropicThinkingEffort)
|
||||
resolved.ThinkingBudgetTokens = channel.ThinkingBudgetTokens
|
||||
resolved.ProviderStreamIdleTimeout = router.resolver.ProviderStreamIdleTimeout(ctx)
|
||||
runtimeThinkingEffort := normalizeRuntimeThinkingEffort(req.ThinkingEffort)
|
||||
if runtimeThinkingEffort != "" {
|
||||
resolved.ThinkingEffort = runtimeThinkingEffort
|
||||
if runtimeThinkingEffort == "disabled" {
|
||||
resolved.ReasoningEffort = ""
|
||||
resolved.AnthropicThinkingEffort = ""
|
||||
} else {
|
||||
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(runtimeThinkingEffort)
|
||||
resolved.AnthropicThinkingEffort = runtimeThinkingEffort
|
||||
}
|
||||
} else {
|
||||
resolved.ThinkingEffort = ""
|
||||
}
|
||||
if resolved.MaxTokens <= 0 && channel.MaxTokens > 0 {
|
||||
resolved.MaxTokens = channel.MaxTokens
|
||||
}
|
||||
if req.MaxTokens > 0 && (resolved.AnthropicMaxTokens <= 0 || req.MaxTokens < resolved.AnthropicMaxTokens) {
|
||||
resolved.AnthropicMaxTokens = req.MaxTokens
|
||||
}
|
||||
if resolved.AnthropicMaxTokens <= 0 && resolved.MaxTokens > 0 {
|
||||
resolved.AnthropicMaxTokens = resolved.MaxTokens
|
||||
}
|
||||
if resolved.ProviderModelID == "" {
|
||||
resolved.ProviderModelID = strings.TrimSpace(req.ModelID)
|
||||
}
|
||||
resolved.Messages = sanitizeProviderMessages(req.Messages)
|
||||
if resolved.RequestKnobs != nil {
|
||||
resolved.RequestKnobs["max_tokens"] = resolved.MaxTokens
|
||||
if runtimeThinkingEffort != "" {
|
||||
resolved.RequestKnobs["runtime_thinking_effort"] = runtimeThinkingEffort
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "runtime_thinking_effort")
|
||||
}
|
||||
if resolved.Provider == "openai" {
|
||||
if strings.TrimSpace(resolved.ReasoningEffort) != "" {
|
||||
resolved.RequestKnobs["reasoning_effort"] = strings.TrimSpace(resolved.ReasoningEffort)
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "reasoning_effort")
|
||||
}
|
||||
resolved.RequestKnobs["openai_endpoint"] = resolved.OpenAIEndpoint
|
||||
resolved.RequestKnobs["openai_extra_params_enabled"] = resolved.OpenAIExtraParamsEnabled
|
||||
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
|
||||
} else if resolved.Provider == "anthropic" {
|
||||
delete(resolved.RequestKnobs, "reasoning_effort")
|
||||
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
|
||||
resolved.RequestKnobs["anthropic_extra_params_enabled"] = resolved.AnthropicExtraParamsEnabled
|
||||
anthropicMaxTokens := maxAnthropicTokens(resolved)
|
||||
resolved.RequestKnobs["max_tokens"] = anthropicMaxTokens
|
||||
resolved.RequestKnobs["anthropic_max_tokens"] = anthropicMaxTokens
|
||||
if strings.TrimSpace(resolved.AnthropicThinkingEffort) != "" {
|
||||
resolved.RequestKnobs["anthropic_thinking_effort"] = anthropicThinkingEffort(resolved)
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "anthropic_thinking_effort")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch resolved.Provider {
|
||||
case "anthropic":
|
||||
return router.anthropic.Stream(ctx, resolved, sink)
|
||||
case "openai":
|
||||
return router.openai.Stream(ctx, resolved, sink)
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider %q", resolved.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeProviderMessages removes replay-only placeholders and trims trailing
|
||||
// assistant prefill so providers that require a user/tool terminal message do
|
||||
// not reject the request.
|
||||
func sanitizeProviderMessages(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make([]Message, 0, len(input))
|
||||
for _, message := range input {
|
||||
if isAssistantPlaceholderMessage(message) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, message)
|
||||
}
|
||||
filtered = mergeAdjacentAssistantToolCallMessages(filtered)
|
||||
filtered = trimDanglingAssistantToolCalls(filtered)
|
||||
for len(filtered) > 0 && isAssistantPrefillMessage(filtered[len(filtered)-1]) {
|
||||
filtered = filtered[:len(filtered)-1]
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isAssistantPlaceholderMessage(message Message) bool {
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(message.ToolCalls) > 0 || len(message.ContentParts) > 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningSignature) != "" {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(message.Content) {
|
||||
case "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAssistantPrefillMessage(message Message) bool {
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(message.Content) != "" || strings.TrimSpace(message.ReasoningContent) != ""
|
||||
}
|
||||
|
||||
func mergeAdjacentAssistantToolCallMessages(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make([]Message, 0, len(input))
|
||||
for _, raw := range input {
|
||||
message := cloneProviderMessage(raw)
|
||||
if mergeProviderAssistantToolCalls(&merged, message) {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, message)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func cloneProviderMessage(message Message) Message {
|
||||
cloned := message
|
||||
if len(message.ContentParts) > 0 {
|
||||
cloned.ContentParts = append([]ContentPart(nil), message.ContentParts...)
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
cloned.ToolCalls = append([]ToolCallDescriptor(nil), message.ToolCalls...)
|
||||
}
|
||||
if len(message.OpenAIResponsesReasoningSummary) > 0 {
|
||||
cloned.OpenAIResponsesReasoningSummary = append([]byte(nil), message.OpenAIResponsesReasoningSummary...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mergeProviderAssistantToolCalls(messages *[]Message, message Message) bool {
|
||||
if len(*messages) == 0 {
|
||||
return false
|
||||
}
|
||||
last := &(*messages)[len(*messages)-1]
|
||||
if !canMergeProviderAssistantToolCalls(*last, message) {
|
||||
return false
|
||||
}
|
||||
startIndex := len(last.ToolCalls)
|
||||
for index, toolCall := range message.ToolCalls {
|
||||
item := toolCall
|
||||
item.Index = startIndex + index
|
||||
last.ToolCalls = append(last.ToolCalls, item)
|
||||
}
|
||||
last.ReasoningContent = mergeProviderReasoning(last.ReasoningContent, message.ReasoningContent)
|
||||
mergeProviderReasoningMetadata(last, message)
|
||||
return true
|
||||
}
|
||||
|
||||
func canMergeProviderAssistantToolCalls(last Message, current Message) bool {
|
||||
if strings.TrimSpace(last.Role) != "assistant" || strings.TrimSpace(current.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(last.ToolCalls) == 0 || len(current.ToolCalls) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(last.ToolCallID) != "" || strings.TrimSpace(last.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(current.ToolCallID) != "" || strings.TrimSpace(current.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(current.Content) != "" || len(current.ContentParts) > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeProviderReasoning(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return left + "\n\n" + right
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningSignature(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningSignatureSource(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningMetadata(last *Message, current Message) {
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
leftSignature := strings.TrimSpace(last.ReasoningSignature)
|
||||
rightSignature := strings.TrimSpace(current.ReasoningSignature)
|
||||
mergedSignature := mergeProviderReasoningSignature(leftSignature, rightSignature)
|
||||
last.ReasoningSignature = mergedSignature
|
||||
if mergedSignature == "" {
|
||||
last.ReasoningSignatureSource = ""
|
||||
last.OpenAIResponsesReasoningID = ""
|
||||
last.OpenAIResponsesReasoningStatus = ""
|
||||
last.OpenAIResponsesReasoningSummary = nil
|
||||
return
|
||||
}
|
||||
if leftSignature == "" && rightSignature != "" {
|
||||
last.ReasoningSignatureSource = strings.TrimSpace(current.ReasoningSignatureSource)
|
||||
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
|
||||
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
|
||||
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
|
||||
return
|
||||
}
|
||||
if leftSignature == rightSignature {
|
||||
last.ReasoningSignatureSource = mergeProviderReasoningSignatureSource(last.ReasoningSignatureSource, current.ReasoningSignatureSource)
|
||||
if strings.TrimSpace(last.OpenAIResponsesReasoningID) == "" {
|
||||
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
|
||||
}
|
||||
if strings.TrimSpace(last.OpenAIResponsesReasoningStatus) == "" {
|
||||
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
|
||||
}
|
||||
if len(last.OpenAIResponsesReasoningSummary) == 0 {
|
||||
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trimDanglingAssistantToolCalls(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]Message, 0, len(input))
|
||||
for index := 0; index < len(input); index++ {
|
||||
message := cloneProviderMessage(input[index])
|
||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
|
||||
end := index + 1
|
||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
|
||||
toolCallID := strings.TrimSpace(input[end].ToolCallID)
|
||||
if toolCallID != "" {
|
||||
responded[toolCallID] = struct{}{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
toolCallID := strings.TrimSpace(toolCall.ID)
|
||||
if _, ok := responded[toolCallID]; !ok {
|
||||
continue
|
||||
}
|
||||
item := toolCall
|
||||
item.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, item)
|
||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(nextToolCalls) > 0 {
|
||||
message.ToolCalls = nextToolCalls
|
||||
trimmed = append(trimmed, message)
|
||||
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
|
||||
toolMessage := cloneProviderMessage(input[toolIndex])
|
||||
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, toolMessage)
|
||||
}
|
||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
message.ToolCalls = nil
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
|
||||
index = end - 1
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeRuntimeThinkingEffort(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "disabled", "low", "medium", "high", "xhigh", "max":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
case "disable", "off", "none", "false", "no", "0":
|
||||
return "disabled"
|
||||
case "very_high", "very-high", "veryhigh", "x-high", "extra_high", "extra-high", "extrahigh":
|
||||
return "xhigh"
|
||||
case "maximum":
|
||||
return "max"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIReasoningEffortFromRuntime(runtimeThinkingEffort string) string {
|
||||
switch normalizeRuntimeThinkingEffort(runtimeThinkingEffort) {
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return normalizeRuntimeThinkingEffort(runtimeThinkingEffort)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProviderStreamIdleTimeout = 4 * time.Minute
|
||||
minProviderStreamIdleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type providerStreamIdleWatchdog struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelCauseFunc
|
||||
timeout time.Duration
|
||||
timer *time.Timer
|
||||
|
||||
mu sync.Mutex
|
||||
body io.Closer
|
||||
stopped bool
|
||||
timedOut bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newProviderStreamIdleWatchdog(parent context.Context, timeout time.Duration) (context.Context, *providerStreamIdleWatchdog) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
timeout = normalizeProviderStreamIdleTimeoutDuration(timeout)
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
watchdog := &providerStreamIdleWatchdog{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
timeout: timeout,
|
||||
err: providerStreamIdleTimeoutError(timeout),
|
||||
}
|
||||
watchdog.timer = time.AfterFunc(watchdog.timeout, watchdog.expire)
|
||||
return ctx, watchdog
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) AttachBody(body io.Closer) {
|
||||
if watchdog == nil || body == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
watchdog.body = body
|
||||
shouldClose := watchdog.timedOut || watchdog.stopped
|
||||
watchdog.mu.Unlock()
|
||||
if shouldClose {
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) MarkEffectiveContent() {
|
||||
if watchdog == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
defer watchdog.mu.Unlock()
|
||||
if watchdog.stopped || watchdog.timedOut || watchdog.timer == nil {
|
||||
return
|
||||
}
|
||||
watchdog.timer.Reset(watchdog.timeout)
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) Stop() {
|
||||
if watchdog == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
if watchdog.stopped {
|
||||
watchdog.mu.Unlock()
|
||||
return
|
||||
}
|
||||
watchdog.stopped = true
|
||||
watchdog.body = nil
|
||||
if watchdog.timer != nil {
|
||||
watchdog.timer.Stop()
|
||||
}
|
||||
watchdog.mu.Unlock()
|
||||
watchdog.cancel(nil)
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) Err() error {
|
||||
if watchdog == nil {
|
||||
return nil
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
defer watchdog.mu.Unlock()
|
||||
if watchdog.timedOut {
|
||||
return watchdog.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) expire() {
|
||||
watchdog.mu.Lock()
|
||||
if watchdog.stopped || watchdog.timedOut {
|
||||
watchdog.mu.Unlock()
|
||||
return
|
||||
}
|
||||
watchdog.timedOut = true
|
||||
body := watchdog.body
|
||||
err := watchdog.err
|
||||
watchdog.mu.Unlock()
|
||||
|
||||
watchdog.cancel(err)
|
||||
if body != nil {
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderStreamIdleTimeoutDuration(timeout time.Duration) time.Duration {
|
||||
if timeout <= 0 {
|
||||
return defaultProviderStreamIdleTimeout
|
||||
}
|
||||
if timeout < minProviderStreamIdleTimeout {
|
||||
return minProviderStreamIdleTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func providerStreamIdleTimeoutError(timeout time.Duration) error {
|
||||
seconds := int(timeout / time.Second)
|
||||
if seconds > 0 && timeout == time.Duration(seconds)*time.Second {
|
||||
return fmt.Errorf("provider stream idle timeout after %ds without effective content", seconds)
|
||||
}
|
||||
return fmt.Errorf("provider stream idle timeout after %s without effective content", timeout)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProviderToolCallIDLen = 64
|
||||
toolCallNamespaceHashLen = 12
|
||||
toolCallValueHashLen = 12
|
||||
)
|
||||
|
||||
// namespaceToolCallID 为 provider 原始 tool call id 增加 model-call 级别命名空间,
|
||||
// 避免像 functions.Shell:0 这类跨轮复用的 id 在客户端被误判为同一个 bubble。
|
||||
//
|
||||
// OpenAI 等 provider 对 tool_call_id 长度有限制,因此这里使用 model_call_id 的短哈希
|
||||
// 而不是完整 UUID,保证内部存储的 tool_call_id 既稳定又能安全回放给 provider。
|
||||
func namespaceToolCallID(modelCallID string, rawToolCallID string) string {
|
||||
raw := strings.TrimSpace(rawToolCallID)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(raw, "::") {
|
||||
return providerToolCallID(raw)
|
||||
}
|
||||
model := strings.TrimSpace(modelCallID)
|
||||
if model == "" {
|
||||
return providerToolCallID(raw)
|
||||
}
|
||||
return buildProviderSafeToolCallID(shortToolCallHash(model, toolCallNamespaceHashLen), raw)
|
||||
}
|
||||
|
||||
// providerToolCallID 把内部持久化的 tool_call_id 规整成 provider 可接受的安全长度。
|
||||
// 这样旧会话里已经落盘的 legacy "<modelCallID>::<rawID>" 也能继续回放。
|
||||
func providerToolCallID(toolCallID string) string {
|
||||
trimmed := strings.TrimSpace(toolCallID)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
namespace, raw, ok := splitLegacyToolCallID(trimmed)
|
||||
if ok {
|
||||
return buildProviderSafeToolCallID(shortToolCallHash(namespace, toolCallNamespaceHashLen), raw)
|
||||
}
|
||||
if len(trimmed) <= maxProviderToolCallIDLen {
|
||||
return trimmed
|
||||
}
|
||||
return buildProviderSafeToolCallID("", trimmed)
|
||||
}
|
||||
|
||||
type providerToolCallDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunctionShape `json:"function"`
|
||||
}
|
||||
|
||||
func normalizeToolCallDescriptors(toolCalls []ToolCallDescriptor) []providerToolCallDescriptor {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := make([]providerToolCallDescriptor, 0, len(toolCalls))
|
||||
for _, toolCall := range toolCalls {
|
||||
item := providerToolCallDescriptor{
|
||||
ID: providerToolCallID(toolCall.ID),
|
||||
Index: toolCall.Index,
|
||||
Type: toolCall.Type,
|
||||
Function: toolCall.Function,
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func buildProviderSafeToolCallID(namespace string, raw string) string {
|
||||
trimmedRaw := strings.TrimSpace(raw)
|
||||
if trimmedRaw == "" {
|
||||
return ""
|
||||
}
|
||||
if namespace == "" && len(trimmedRaw) <= maxProviderToolCallIDLen && !strings.Contains(trimmedRaw, "::") {
|
||||
return trimmedRaw
|
||||
}
|
||||
|
||||
prefix := "tc"
|
||||
if namespace != "" {
|
||||
prefix += "_" + namespace
|
||||
}
|
||||
candidate := prefix + "_" + trimmedRaw
|
||||
if len(candidate) <= maxProviderToolCallIDLen {
|
||||
return candidate
|
||||
}
|
||||
|
||||
rawHash := shortToolCallHash(trimmedRaw, toolCallValueHashLen)
|
||||
remaining := maxProviderToolCallIDLen - len(prefix) - len(rawHash) - 2
|
||||
if remaining <= 0 {
|
||||
return prefix + "_" + rawHash
|
||||
}
|
||||
suffix := trimmedRaw
|
||||
if len(suffix) > remaining {
|
||||
suffix = suffix[len(suffix)-remaining:]
|
||||
}
|
||||
return prefix + "_" + rawHash + "_" + suffix
|
||||
}
|
||||
|
||||
func splitLegacyToolCallID(value string) (namespace string, raw string, ok bool) {
|
||||
namespace, raw, ok = strings.Cut(strings.TrimSpace(value), "::")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
namespace = strings.TrimSpace(namespace)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if namespace == "" || raw == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return namespace, raw, true
|
||||
}
|
||||
|
||||
func shortToolCallHash(value string, size int) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
encoded := hex.EncodeToString(sum[:])
|
||||
if size <= 0 || size > len(encoded) {
|
||||
return encoded
|
||||
}
|
||||
return encoded[:size]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// types.go 定义模型适配层的统一请求、事件与路由接口。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// ReasoningSignatureSourceAnthropic 表示 signature 来自 Anthropic thinking signature。
|
||||
ReasoningSignatureSourceAnthropic = "anthropic"
|
||||
// ReasoningSignatureSourceOpenAIResponses 表示 signature 来自 OpenAI Responses encrypted reasoning content。
|
||||
ReasoningSignatureSourceOpenAIResponses = "openai_responses"
|
||||
)
|
||||
|
||||
// Message 表示模型适配层统一使用的消息结构。
|
||||
type Message struct {
|
||||
// Role 表示消息角色。
|
||||
Role string `json:"role"`
|
||||
// Content 表示消息文本内容。
|
||||
Content string `json:"content"`
|
||||
// ContentParts 表示消息中的结构化内容块,例如文本或图片。
|
||||
ContentParts []ContentPart `json:"content_parts,omitempty"`
|
||||
// ReasoningContent 表示推理内容(用于支持 reasoning 的模型)。
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
// ReasoningSignature 表示 provider 对推理内容签发的签名(如 Anthropic thinking signature)。
|
||||
ReasoningSignature string `json:"reasoning_signature,omitempty"`
|
||||
// ReasoningSignatureSource 表示 reasoning signature 的 provider 语义来源。
|
||||
ReasoningSignatureSource string `json:"reasoning_signature_source,omitempty"`
|
||||
// OpenAIResponsesReasoningID 保存 Responses reasoning output item 的原始 id。
|
||||
OpenAIResponsesReasoningID string `json:"openai_responses_reasoning_id,omitempty"`
|
||||
// OpenAIResponsesReasoningStatus 保存 Responses reasoning output item 的原始 status。
|
||||
OpenAIResponsesReasoningStatus string `json:"openai_responses_reasoning_status,omitempty"`
|
||||
// OpenAIResponsesReasoningSummary 保存 Responses reasoning output item 的原始 summary。
|
||||
OpenAIResponsesReasoningSummary json.RawMessage `json:"openai_responses_reasoning_summary,omitempty"`
|
||||
// ToolCalls 表示 assistant 发起的函数调用。
|
||||
ToolCalls []ToolCallDescriptor `json:"tool_calls,omitempty"`
|
||||
// ToolCallID 表示 tool role 关联的调用 id。
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
// Name 表示 tool role 的工具名。
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunctionShape `json:"function"`
|
||||
OpenAIResponsesID string `json:"openai_responses_id,omitempty"`
|
||||
OpenAIResponsesCallID string `json:"openai_responses_call_id,omitempty"`
|
||||
OpenAIResponsesStatus string `json:"openai_responses_status,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallFunctionShape struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// StreamRequest 表示一次统一的模型流请求。
|
||||
type StreamRequest struct {
|
||||
// RequestID 表示当前模型调用所属 request。
|
||||
RequestID string
|
||||
// RunID 表示当前模型调用所属 run。
|
||||
RunID string
|
||||
// ModelCallID 表示当前模型调用标识。
|
||||
ModelCallID string
|
||||
// ConversationID 表示当前模型调用所属会话,用于稳定 provider 侧 prompt cache 路由。
|
||||
ConversationID string
|
||||
// Mode 表示当前运行模式。
|
||||
Mode agentv1.AgentMode
|
||||
// ModelID 表示当前模型标识。
|
||||
ModelID string
|
||||
// ThinkingEffort 表示客户端在本轮运行时选择的思考强度覆盖。
|
||||
ThinkingEffort string
|
||||
// Provider 表示目标 provider 类型,例如 openai 或 anthropic。
|
||||
Provider string
|
||||
// BaseURL 表示请求应发送到的 provider 基础地址。
|
||||
BaseURL string
|
||||
// APIKey 表示 provider 鉴权凭据。
|
||||
APIKey string
|
||||
// ProviderModelID 表示 provider 侧真实模型标识。
|
||||
ProviderModelID string
|
||||
// ResolvedChannelID 表示本次请求实际命中的 adapter 渠道 ID。
|
||||
ResolvedChannelID string
|
||||
// ResolvedChannelName 表示本次请求实际命中的 adapter 展示名。
|
||||
ResolvedChannelName string
|
||||
// ResolvedContextWindowTokens 表示本次请求实际命中的 adapter 上下文窗口。
|
||||
ResolvedContextWindowTokens int
|
||||
// ReasoningEffort 表示 OpenAI 兼容 provider 的推理强度。
|
||||
ReasoningEffort string
|
||||
// OpenAIEndpoint 表示 OpenAI 兼容 provider 使用的 API 端点。
|
||||
OpenAIEndpoint string
|
||||
// OpenAIExtraParamsEnabled 表示是否启用 OpenAI 额外请求参数。
|
||||
OpenAIExtraParamsEnabled bool
|
||||
// OpenAIExtraParamsJSON 表示 OpenAI 额外请求参数 JSON 对象。
|
||||
OpenAIExtraParamsJSON string
|
||||
// CustomHeadersEnabled 表示是否启用自定义请求头。
|
||||
CustomHeadersEnabled bool
|
||||
// CustomHeadersJSON 表示自定义请求头 JSON 对象。
|
||||
CustomHeadersJSON string
|
||||
// AnthropicExtraParamsEnabled 表示是否启用 Anthropic 额外请求参数。
|
||||
AnthropicExtraParamsEnabled bool
|
||||
// AnthropicExtraParamsJSON 表示 Anthropic 额外请求参数 JSON 对象。
|
||||
AnthropicExtraParamsJSON string
|
||||
// AnthropicMaxTokens 表示 Anthropic 兼容 provider 的 max_tokens。
|
||||
AnthropicMaxTokens int
|
||||
// AnthropicThinkingEffort 表示 Anthropic adaptive thinking 的 output_config.effort。
|
||||
AnthropicThinkingEffort string
|
||||
// ThinkingBudgetTokens 表示 Anthropic thinking 预算。
|
||||
ThinkingBudgetTokens int
|
||||
// Messages 表示按顺序排列的消息列表。
|
||||
Messages []Message
|
||||
// StableMessageCount 表示 messages 中可作为稳定缓存前缀的 provider-visible 消息数量。
|
||||
StableMessageCount int
|
||||
// Tools 表示原始工具描述 JSON 列表。
|
||||
Tools []json.RawMessage
|
||||
// MaxTokens 表示本轮最大输出 token 数。
|
||||
MaxTokens int
|
||||
// Stream 表示当前请求必须使用流式。
|
||||
Stream bool
|
||||
// RequestKnobs 保存本轮请求的附加参数摘要。
|
||||
RequestKnobs map[string]any
|
||||
// CompileSummary 保存当前 prompt 编译摘要。
|
||||
CompileSummary string
|
||||
// Observer 负责写入 request-scoped LLM 工件。
|
||||
Observer LLMArtifactObserver
|
||||
// ArtifactPaths 用于由 adapter 回填工件路径。
|
||||
ArtifactPaths *LLMArtifactPaths
|
||||
// RequestBodyOverride 表示直接复用的 provider 原始请求体;设置后由 adapter 原样发送。
|
||||
RequestBodyOverride map[string]any
|
||||
// ProviderStreamIdleTimeout 表示 provider 流式响应无有效内容时的空闲超时。
|
||||
ProviderStreamIdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// LLMArtifactPaths 表示一次模型调用相关工件路径。
|
||||
type LLMArtifactPaths struct {
|
||||
RequestPath string
|
||||
ResponsePath string
|
||||
SummaryPath string
|
||||
}
|
||||
|
||||
// LLMArtifactObserver 定义模型调用原始工件写入接口。
|
||||
type LLMArtifactObserver interface {
|
||||
RecordLLMRequest(requestID string, runID string, modelCallID string, payload map[string]any) (string, error)
|
||||
AppendLLMResponseChunk(requestID string, runID string, modelCallID string, chunk string) (string, error)
|
||||
RecordLLMSummary(requestID string, runID string, modelCallID string, payload map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// ModelEventKind 表示统一模型事件类型。
|
||||
type ModelEventKind string
|
||||
|
||||
const (
|
||||
// ModelEventKindTextDelta 表示文本增量事件。
|
||||
ModelEventKindTextDelta ModelEventKind = "text_delta"
|
||||
// ModelEventKindThinkingDelta 表示思考增量事件。
|
||||
ModelEventKindThinkingDelta ModelEventKind = "thinking_delta"
|
||||
// ModelEventKindThinkingCompleted 表示思考结束事件。
|
||||
ModelEventKindThinkingCompleted ModelEventKind = "thinking_completed"
|
||||
// ModelEventKindPartialToolCall 表示工具调用已开始,但参数仍在流式生成中。
|
||||
ModelEventKindPartialToolCall ModelEventKind = "partial_tool_call"
|
||||
// ModelEventKindToolCallDelta 表示工具调用参数或输出的流式增量。
|
||||
ModelEventKindToolCallDelta ModelEventKind = "tool_call_delta"
|
||||
// ModelEventKindToolLikeCompleted 表示工具意图已完整收口。
|
||||
ModelEventKindToolLikeCompleted ModelEventKind = "tool_like_completed"
|
||||
// ModelEventKindTurnFinished 表示当前模型回合结束。
|
||||
ModelEventKindTurnFinished ModelEventKind = "turn_finished"
|
||||
// ModelEventKindProviderError 表示 provider 侧返回错误。
|
||||
ModelEventKindProviderError ModelEventKind = "provider_error"
|
||||
)
|
||||
|
||||
// ModelEvent 表示一条统一模型事件。
|
||||
type ModelEvent struct {
|
||||
// Kind 表示事件类型。
|
||||
Kind ModelEventKind
|
||||
// OccurredAt 表示当前 provider 事件发生时间。
|
||||
OccurredAt time.Time
|
||||
// Provider 表示当前事件所属 provider。
|
||||
Provider string
|
||||
// Model 表示当前事件所属模型标识。
|
||||
Model string
|
||||
// Text 表示文本增量。
|
||||
Text string
|
||||
// ThinkingStyle 表示思考样式。
|
||||
ThinkingStyle agentv1.ThinkingStyle
|
||||
// ThinkingDurationMS 表示思考持续时长。
|
||||
ThinkingDurationMS int32
|
||||
// ThinkingSignature 表示 provider 返回的思考签名(如 Anthropic signature_delta)。
|
||||
ThinkingSignature string
|
||||
// ThinkingSignatureSource 表示思考签名的 provider 语义来源。
|
||||
ThinkingSignatureSource string
|
||||
// ProviderItemID 保存 provider 原始 output item id,用于 stateless Responses replay。
|
||||
ProviderItemID string
|
||||
// ProviderStatus 保存 provider 原始 output item status,用于 stateless Responses replay。
|
||||
ProviderStatus string
|
||||
// ProviderSummary 保存 provider 原始 output item summary,用于 stateless Responses replay。
|
||||
ProviderSummary json.RawMessage
|
||||
// ProviderCallID 保存 provider 原始 tool/function call id,用于 stateless Responses replay。
|
||||
ProviderCallID string
|
||||
// ToolCallID 表示当前 partial/delta 对应的工具调用标识。
|
||||
ToolCallID string
|
||||
// ToolCall 保存 partial tool call 当前可公开的结构化快照。
|
||||
ToolCall *agentv1.ToolCall
|
||||
// ToolCallDelta 保存与当前工具调用相关的流式增量。
|
||||
ToolCallDelta *agentv1.ToolCallDelta
|
||||
// ArgsTextDelta 保存原始工具参数文本增量,供兼容层透传。
|
||||
ArgsTextDelta string
|
||||
// InputTokens 表示当前已知的输入 token 数。
|
||||
InputTokens int64
|
||||
// OutputTokens 表示当前已知的输出 token 数。
|
||||
OutputTokens int64
|
||||
// CacheReadTokens 表示当前已知的 cache read token 数。
|
||||
CacheReadTokens int64
|
||||
// CacheWriteTokens 表示当前已知的 cache write token 数。
|
||||
CacheWriteTokens int64
|
||||
// UsagePresent 表示 provider 本次流里实际返回过 usage 对象。
|
||||
UsagePresent bool
|
||||
// CacheReadPresent 表示 provider 明确返回了 cache read token 字段。
|
||||
CacheReadPresent bool
|
||||
// CacheWritePresent 表示 provider 明确返回了 cache write token 字段。
|
||||
CacheWritePresent bool
|
||||
// ToolInvocation 表示完成收口的工具调用意图。
|
||||
ToolInvocation *runtimecore.ToolInvocation
|
||||
// FinishReason 表示回合结束原因。
|
||||
FinishReason string
|
||||
// Err 表示 provider 错误。
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelAdapter 定义具体 provider 适配器接口。
|
||||
type ModelAdapter interface {
|
||||
// Stream 按流式方式发送请求,并持续产出统一模型事件。
|
||||
Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error
|
||||
}
|
||||
|
||||
// ModelAdapterRouter 定义 provider 路由接口。
|
||||
type ModelAdapterRouter interface {
|
||||
// Stream 根据模型标识选择底层 provider 适配器。
|
||||
Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package modeladapter
|
||||
|
||||
const (
|
||||
// ClaudeCodeUserAgent 用于将渠道模型请求伪装为 Claude Code 客户端。
|
||||
ClaudeCodeUserAgent = "claude-cli/2.1.19 (external, sdk-cli)"
|
||||
// AnthropicClaudeCodeUserAgent 用于 Anthropic provider 的 Claude Code UA 兼容。
|
||||
AnthropicClaudeCodeUserAgent = "claude-cli/1.0.25"
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package promptengine
|
||||
|
||||
// ContentPart 表示一条消息中的结构化内容块。
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Image *ImageContent `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// ImageContent 表示消息中携带的一张图片。
|
||||
type ImageContent struct {
|
||||
MIMEType string `json:"mime_type,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package promptengine 负责把静态 prompt 资产、会话状态与外部结果编译成模型请求输入。
|
||||
package promptengine
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
package promptengine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
// BuildUserQueryReplayMessage 构造一条可直接回放给模型的用户消息。
|
||||
func BuildUserQueryReplayMessage(text string) (Message, bool) {
|
||||
return buildUserReplayMessage(strings.TrimSpace(text), nil)
|
||||
}
|
||||
|
||||
// BuildUserMessageReplayMessage 把包含 selected_context 的用户消息还原为 replay message。
|
||||
func BuildUserMessageReplayMessage(userMessage *agentv1.UserMessage) (Message, bool) {
|
||||
if userMessage == nil {
|
||||
return Message{}, false
|
||||
}
|
||||
return buildUserReplayMessage(strings.TrimSpace(userMessage.GetText()), userMessage.GetSelectedContext())
|
||||
}
|
||||
|
||||
func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContext) (Message, bool) {
|
||||
images := buildSelectedImageContentParts(selectedContext)
|
||||
sections := make([]string, 0, 4)
|
||||
if text != "" {
|
||||
sections = append(sections, formatMessageText(fmt.Sprintf("<user_query>\n%s\n</user_query>", text)))
|
||||
}
|
||||
if ideState := buildSelectedIDEStatePromptSection(selectedContext); ideState != "" {
|
||||
sections = append(sections, ideState)
|
||||
}
|
||||
if selectedFiles := buildSelectedFilesPromptSection(selectedContext); selectedFiles != "" {
|
||||
sections = append(sections, selectedFiles)
|
||||
}
|
||||
content := strings.TrimSpace(strings.Join(sections, "\n\n"))
|
||||
if content == "" && len(images) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
}, true
|
||||
}
|
||||
|
||||
parts := make([]ContentPart, 0, len(images)+1)
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{
|
||||
Type: "text",
|
||||
Text: content,
|
||||
})
|
||||
}
|
||||
parts = append(parts, images...)
|
||||
return Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
ContentParts: parts,
|
||||
}, true
|
||||
}
|
||||
|
||||
func buildSelectedIDEStatePromptSection(selectedContext *agentv1.SelectedContext) string {
|
||||
if selectedContext == nil || selectedContext.GetInvocationContext() == nil {
|
||||
return ""
|
||||
}
|
||||
ideState := selectedContext.GetInvocationContext().GetIdeState()
|
||||
if ideState == nil {
|
||||
return ""
|
||||
}
|
||||
sections := make([]string, 0, 2)
|
||||
if visible := buildIDEStateFilesPromptSection("visible_files", ideState.GetVisibleFiles()); visible != "" {
|
||||
sections = append(sections, visible)
|
||||
}
|
||||
if recent := buildIDEStateFilesPromptSection("recently_viewed_files", ideState.GetRecentlyViewedFiles()); recent != "" {
|
||||
sections = append(sections, recent)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(sections, "\n\n"))
|
||||
}
|
||||
|
||||
func buildIDEStateFilesPromptSection(tag string, files []*agentv1.InvocationContext_IdeState_File) string {
|
||||
if len(files) == 0 {
|
||||
return ""
|
||||
}
|
||||
entries := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
attrs := make([]string, 0, 4)
|
||||
if path := strings.TrimSpace(file.GetPath()); path != "" {
|
||||
attrs = append(attrs, fmt.Sprintf(`path="%s"`, escapePromptXML(path)))
|
||||
}
|
||||
if relativePath := strings.TrimSpace(file.GetRelativePath()); relativePath != "" {
|
||||
attrs = append(attrs, fmt.Sprintf(`relative_path="%s"`, escapePromptXML(relativePath)))
|
||||
}
|
||||
if cursor := file.GetCursorPosition(); cursor != nil && cursor.GetLine() > 0 {
|
||||
attrs = append(attrs, fmt.Sprintf(`cursor_line="%d"`, cursor.GetLine()))
|
||||
}
|
||||
if totalLines := file.GetTotalLines(); totalLines > 0 {
|
||||
attrs = append(attrs, fmt.Sprintf(`total_lines="%d"`, totalLines))
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, "<file "+strings.Join(attrs, " ")+" />")
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("<%s>\n%s\n</%s>", tag, strings.Join(entries, "\n"), tag)
|
||||
}
|
||||
|
||||
func buildSelectedFilesPromptSection(selectedContext *agentv1.SelectedContext) string {
|
||||
if selectedContext == nil || len(selectedContext.GetFiles()) == 0 {
|
||||
return ""
|
||||
}
|
||||
entries := make([]string, 0, len(selectedContext.GetFiles()))
|
||||
for _, file := range selectedContext.GetFiles() {
|
||||
if file == nil || strings.TrimSpace(file.GetContent()) == "" {
|
||||
continue
|
||||
}
|
||||
attrs := make([]string, 0, 2)
|
||||
if path := strings.TrimSpace(file.GetPath()); path != "" {
|
||||
attrs = append(attrs, fmt.Sprintf(`path="%s"`, escapePromptXML(path)))
|
||||
}
|
||||
if relativePath := strings.TrimSpace(file.GetRelativePath()); relativePath != "" {
|
||||
attrs = append(attrs, fmt.Sprintf(`relative_path="%s"`, escapePromptXML(relativePath)))
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, "<file "+strings.Join(attrs, " ")+">\n"+file.GetContent()+"\n</file>")
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "<selected_files>\n" + strings.Join(entries, "\n\n") + "\n</selected_files>"
|
||||
}
|
||||
|
||||
func buildSelectedImageContentParts(selectedContext *agentv1.SelectedContext) []ContentPart {
|
||||
if selectedContext == nil {
|
||||
return nil
|
||||
}
|
||||
parts := make([]ContentPart, 0, len(selectedContext.GetSelectedImages()))
|
||||
for _, image := range selectedContext.GetSelectedImages() {
|
||||
if image == nil {
|
||||
continue
|
||||
}
|
||||
data := image.GetData()
|
||||
if len(data) == 0 {
|
||||
data = image.GetBlobIdWithData().GetData()
|
||||
}
|
||||
if len(data) == 0 && strings.TrimSpace(image.GetPath()) == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ContentPart{
|
||||
Type: "image",
|
||||
Image: &ImageContent{
|
||||
MIMEType: strings.TrimSpace(image.GetMimeType()),
|
||||
Path: strings.TrimSpace(image.GetPath()),
|
||||
Data: data,
|
||||
},
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// EncodeReplayMessages 把 canonical replay message 编码为 root_prompt_messages_json。
|
||||
func EncodeReplayMessages(messages []Message) ([][]byte, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
encoded := make([][]byte, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
payload, err := marshalReplayMessage(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded = append(encoded, payload)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func marshalReplayMessage(message Message) ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"role": message.Role,
|
||||
"content": message.Content,
|
||||
}
|
||||
if len(message.ContentParts) > 0 {
|
||||
payload["content_parts"] = message.ContentParts
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningContent) != "" || (strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0) {
|
||||
payload["reasoning_content"] = message.ReasoningContent
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningSignature) != "" {
|
||||
payload["reasoning_signature"] = message.ReasoningSignature
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningSignatureSource) != "" {
|
||||
payload["reasoning_signature_source"] = strings.TrimSpace(message.ReasoningSignatureSource)
|
||||
}
|
||||
if strings.TrimSpace(message.OpenAIResponsesReasoningID) != "" {
|
||||
payload["openai_responses_reasoning_id"] = strings.TrimSpace(message.OpenAIResponsesReasoningID)
|
||||
}
|
||||
if strings.TrimSpace(message.OpenAIResponsesReasoningStatus) != "" {
|
||||
payload["openai_responses_reasoning_status"] = strings.TrimSpace(message.OpenAIResponsesReasoningStatus)
|
||||
}
|
||||
if len(message.OpenAIResponsesReasoningSummary) > 0 {
|
||||
payload["openai_responses_reasoning_summary"] = json.RawMessage(append([]byte(nil), message.OpenAIResponsesReasoningSummary...))
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
payload["tool_calls"] = message.ToolCalls
|
||||
}
|
||||
if strings.TrimSpace(message.ToolCallID) != "" {
|
||||
payload["tool_call_id"] = message.ToolCallID
|
||||
}
|
||||
if strings.TrimSpace(message.Name) != "" {
|
||||
payload["name"] = message.Name
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// DecodeReplayMessages 从 root_prompt_messages_json 解码 canonical replay message。
|
||||
func DecodeReplayMessages(rawItems [][]byte) ([]Message, error) {
|
||||
if len(rawItems) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
messages := make([]Message, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var message Message
|
||||
if err := json.Unmarshal(raw, &message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(message.Role) == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// BuildReplayMessagesFromPendingAssistantOutputs 把 pending assistant raw 还原为 canonical replay message。
|
||||
func BuildReplayMessagesFromPendingAssistantOutputs(rawValues []string) []Message {
|
||||
if len(rawValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
messages := make([]Message, 0, len(rawValues)*3)
|
||||
for _, raw := range rawValues {
|
||||
messages = append(messages, buildMessagesFromPendingAssistantRaw(raw)...)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// BuildLegacyMessagesFromConversationStep 使用 legacy XML 文本形状回放单个 step。
|
||||
func BuildLegacyMessagesFromConversationStep(step *agentv1.ConversationStep) []Message {
|
||||
if step == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch item := step.GetMessage().(type) {
|
||||
case *agentv1.ConversationStep_AssistantMessage:
|
||||
text := strings.TrimSpace(item.AssistantMessage.GetText())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Message{{Role: "assistant", Content: formatMessageText(text)}}
|
||||
case *agentv1.ConversationStep_ThinkingMessage:
|
||||
text := strings.TrimSpace(item.ThinkingMessage.GetText())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Message{{
|
||||
Role: "assistant",
|
||||
Content: formatMessageText(fmt.Sprintf("<thinking>\n%s\n</thinking>", text)),
|
||||
}}
|
||||
case *agentv1.ConversationStep_ToolCall:
|
||||
text := strings.TrimSpace(compactProtoJSON(item.ToolCall))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Message{{
|
||||
Role: "assistant",
|
||||
Content: formatMessageText(fmt.Sprintf("<tool_call>\n%s\n</tool_call>", text)),
|
||||
}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// BuildToolCallReplayMessages 把已完成的 ToolCall step 还原为 native assistant/tool replay message。
|
||||
func BuildToolCallReplayMessages(toolCallID string, toolCall *agentv1.ToolCall) ([]Message, bool) {
|
||||
descriptor, toolResult, ok := extractToolCallReplay(toolCallID, toolCall)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCallDescriptor{descriptor},
|
||||
},
|
||||
toolResult,
|
||||
}, true
|
||||
}
|
||||
|
||||
// BuildToolResultReplayMessage 从已完成的 ToolCall 中提取 tool replay message,
|
||||
// 用于 history 已经单独记录过 assistant tool_call 时仅回放真实工具结果。
|
||||
func BuildToolResultReplayMessage(toolCallID string, toolCall *agentv1.ToolCall) (Message, bool) {
|
||||
if toolCall == nil || strings.TrimSpace(toolCallID) == "" {
|
||||
return Message{}, false
|
||||
}
|
||||
shape, ok := extractToolCallReplayShape(toolCall)
|
||||
if !ok || !shape.HasResult {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{
|
||||
Role: "tool",
|
||||
Content: shape.ResultJSON,
|
||||
ToolCallID: strings.TrimSpace(toolCallID),
|
||||
Name: shape.ToolName,
|
||||
}, true
|
||||
}
|
||||
|
||||
// BuildAssistantToolCallReplayMessage 把未完成或已完成的 ToolCall 还原为 assistant tool-call replay message。
|
||||
func BuildAssistantToolCallReplayMessage(toolCallID string, toolCall *agentv1.ToolCall) (Message, bool) {
|
||||
descriptor, ok := BuildToolCallReplayDescriptor(toolCallID, toolCall)
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCallDescriptor{descriptor},
|
||||
}, true
|
||||
}
|
||||
|
||||
// BuildToolCallReplayDescriptor 从 ToolCall proto 提取 assistant replay 所需的工具调用描述。
|
||||
func BuildToolCallReplayDescriptor(toolCallID string, toolCall *agentv1.ToolCall) (ToolCallDescriptor, bool) {
|
||||
if toolCall == nil || strings.TrimSpace(toolCallID) == "" {
|
||||
return ToolCallDescriptor{}, false
|
||||
}
|
||||
shape, ok := extractToolCallReplayShape(toolCall)
|
||||
if !ok {
|
||||
return ToolCallDescriptor{}, false
|
||||
}
|
||||
return ToolCallDescriptor{
|
||||
ID: strings.TrimSpace(toolCallID),
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: shape.ToolName,
|
||||
Arguments: firstNonEmpty(shape.ArgsJSON, "{}"),
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func extractToolCallReplay(toolCallID string, toolCall *agentv1.ToolCall) (ToolCallDescriptor, Message, bool) {
|
||||
descriptor, ok := BuildToolCallReplayDescriptor(toolCallID, toolCall)
|
||||
if !ok {
|
||||
return ToolCallDescriptor{}, Message{}, false
|
||||
}
|
||||
toolResult, ok := BuildToolResultReplayMessage(toolCallID, toolCall)
|
||||
if !ok {
|
||||
return ToolCallDescriptor{}, Message{}, false
|
||||
}
|
||||
return descriptor, toolResult, true
|
||||
}
|
||||
|
||||
type toolCallReplayShape struct {
|
||||
ToolName string
|
||||
ArgsJSON string
|
||||
ResultJSON string
|
||||
HasResult bool
|
||||
}
|
||||
|
||||
func extractToolCallReplayShape(toolCall *agentv1.ToolCall) (toolCallReplayShape, bool) {
|
||||
if toolCall == nil {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
value := toolCall.ProtoReflect()
|
||||
oneof := value.Descriptor().Oneofs().ByName("tool")
|
||||
if oneof == nil {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
selected := value.WhichOneof(oneof)
|
||||
if selected == nil {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
selectedValue := value.Get(selected)
|
||||
if !selectedValue.IsValid() {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
selectedMessage := selectedValue.Message()
|
||||
if !selectedMessage.IsValid() {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
argsJSON, _ := extractReplayFieldJSON(selectedMessage, "args")
|
||||
resultJSON, hasResult := extractReplayFieldJSON(selectedMessage, "result")
|
||||
toolName := canonicalReplayToolName(string(selected.Name()), string(selectedMessage.Descriptor().Name()), argsJSON, resultJSON)
|
||||
if toolName == "" {
|
||||
return toolCallReplayShape{}, false
|
||||
}
|
||||
return toolCallReplayShape{
|
||||
ToolName: toolName,
|
||||
ArgsJSON: firstNonEmpty(argsJSON, "{}"),
|
||||
ResultJSON: resultJSON,
|
||||
HasResult: hasResult,
|
||||
}, true
|
||||
}
|
||||
|
||||
func extractReplayFieldJSON(message protoreflect.Message, fieldName string) (string, bool) {
|
||||
if !message.IsValid() {
|
||||
return "", false
|
||||
}
|
||||
field := message.Descriptor().Fields().ByName(protoreflect.Name(fieldName))
|
||||
if field == nil || !message.Has(field) {
|
||||
return "", false
|
||||
}
|
||||
value := message.Get(field)
|
||||
if !value.IsValid() {
|
||||
return "", false
|
||||
}
|
||||
child := value.Message()
|
||||
if !child.IsValid() {
|
||||
return "", false
|
||||
}
|
||||
item, ok := child.Interface().(proto.Message)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return compactProtoJSON(item), true
|
||||
}
|
||||
|
||||
func canonicalReplayToolName(fieldName string, messageName string, argsJSON string, resultJSON string) string {
|
||||
switch strings.TrimSpace(fieldName) {
|
||||
case "mcp_tool_call":
|
||||
return "CallMcpTool"
|
||||
case "read_mcp_resource_tool_call":
|
||||
return "FetchMcpResource"
|
||||
case "update_todos_tool_call":
|
||||
return "TodoWrite"
|
||||
case "read_todos_tool_call":
|
||||
return "ReadTodos"
|
||||
case "sem_search_tool_call":
|
||||
return "SemanticSearch"
|
||||
case "edit_tool_call":
|
||||
return replayEditToolName(argsJSON, resultJSON)
|
||||
}
|
||||
trimmed := strings.TrimSuffix(strings.TrimSpace(messageName), "ToolCall")
|
||||
return strings.TrimSpace(trimmed)
|
||||
}
|
||||
|
||||
func replayEditToolName(argsJSON string, resultJSON string) string {
|
||||
if replayEditResultLooksLikeStructuredEdit(resultJSON) {
|
||||
return "Edit"
|
||||
}
|
||||
if editArgsIndicateWrite(argsJSON) {
|
||||
return "Write"
|
||||
}
|
||||
return "Edit"
|
||||
}
|
||||
|
||||
func editArgsIndicateWrite(argsJSON string) bool {
|
||||
trimmed := strings.TrimSpace(argsJSON)
|
||||
if trimmed == "" || trimmed == "{}" || trimmed == "null" {
|
||||
return false
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"stream_content", "streamContent"} {
|
||||
if _, ok := args[key]; !ok {
|
||||
continue
|
||||
}
|
||||
switch args[key].(type) {
|
||||
case string:
|
||||
return true
|
||||
case nil:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func replayEditResultLooksLikeStructuredEdit(resultJSON string) bool {
|
||||
trimmed := strings.TrimSpace(resultJSON)
|
||||
if trimmed == "" || trimmed == "{}" || trimmed == "null" {
|
||||
return false
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
success, ok := payload["success"].(map[string]any)
|
||||
if !ok || len(success) == 0 {
|
||||
return false
|
||||
}
|
||||
if _, ok := success["beforeFullFileContent"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := success["before_full_file_content"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := success["diffString"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := success["diff_string"]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package protocol 负责协议层解码、摘要与上行消息种类判断。
|
||||
package protocol
|
||||
@@ -0,0 +1,224 @@
|
||||
// inbound.go 实现上行协议的解码、摘要与命令类型识别。
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
"cursor/internal/backend/agent/core"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// ReadAppendRequestID 从 BidiAppendRequest 中读取 request_id 文本。
|
||||
func ReadAppendRequestID(input *aiserverv1.BidiAppendRequest) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
return ReadBidiRequestID(input.GetRequestId())
|
||||
}
|
||||
|
||||
// ReadBidiRequestID 从 BidiRequestId 结构中提取并去除首尾空白。
|
||||
func ReadBidiRequestID(input *aiserverv1.BidiRequestId) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(input.GetRequestId())
|
||||
}
|
||||
|
||||
// NormalizeRequestID 规范化请求标识并去除首尾空白。
|
||||
func NormalizeRequestID(requestID string) string {
|
||||
return strings.TrimSpace(requestID)
|
||||
}
|
||||
|
||||
// DecodeAgentClientMessage 解析 hex 文本为 AgentClientMessage,并返回消息类型标签。
|
||||
func DecodeAgentClientMessage(hexData string) (*agentv1.AgentClientMessage, string, error) {
|
||||
trimmed := strings.TrimSpace(hexData)
|
||||
if trimmed == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
payload, err := hex.DecodeString(trimmed)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("bidi append data is not valid hex: %w", err)
|
||||
}
|
||||
clientMessage := &agentv1.AgentClientMessage{}
|
||||
if err := proto.Unmarshal(payload, clientMessage); err != nil {
|
||||
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
|
||||
}
|
||||
return clientMessage, detectClientMessageKind(clientMessage), nil
|
||||
}
|
||||
|
||||
// MapClientMessageToCommandKind 将上行协议消息映射为运行时命令类型。
|
||||
func MapClientMessageToCommandKind(message *agentv1.AgentClientMessage, clientKind string) (runtimecore.CommandKind, error) {
|
||||
switch strings.TrimSpace(clientKind) {
|
||||
case "run_request":
|
||||
return runtimecore.CommandKindRunRequested, nil
|
||||
case "prewarm_request":
|
||||
return runtimecore.CommandKindPrewarmRequested, nil
|
||||
case "conversation_action":
|
||||
if message == nil || message.GetConversationAction() == nil {
|
||||
return "", fmt.Errorf("conversation_action payload is required")
|
||||
}
|
||||
switch message.GetConversationAction().GetAction().(type) {
|
||||
case *agentv1.ConversationAction_CancelAction:
|
||||
return runtimecore.CommandKindCancelRequested, nil
|
||||
case *agentv1.ConversationAction_UserMessageAction,
|
||||
*agentv1.ConversationAction_ResumeAction,
|
||||
*agentv1.ConversationAction_SummarizeAction,
|
||||
*agentv1.ConversationAction_ShellCommandAction,
|
||||
*agentv1.ConversationAction_StartPlanAction,
|
||||
*agentv1.ConversationAction_ExecutePlanAction,
|
||||
*agentv1.ConversationAction_AsyncAskQuestionCompletionAction,
|
||||
*agentv1.ConversationAction_CancelSubagentAction,
|
||||
*agentv1.ConversationAction_BackgroundShellAction,
|
||||
*agentv1.ConversationAction_BackgroundTaskCompletionAction:
|
||||
return runtimecore.CommandKindConversationActionRecordOnly, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported conversation_action payload")
|
||||
}
|
||||
case "exec_client_message":
|
||||
return runtimecore.CommandKindExecClientMessage, nil
|
||||
case "interaction_response":
|
||||
return runtimecore.CommandKindInteractionResponse, nil
|
||||
case "exec_client_control_message":
|
||||
return runtimecore.CommandKindExecClientControlMessage, nil
|
||||
case "client_heartbeat":
|
||||
return runtimecore.CommandKindClientHeartbeat, nil
|
||||
case "kv_client_message":
|
||||
return runtimecore.CommandKindKVClientMessage, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported client message kind: %s", clientKind)
|
||||
}
|
||||
}
|
||||
|
||||
// IsResumeRunRequest 判断当前消息是否为带 `resume_action` 的 `run_request`。
|
||||
func IsResumeRunRequest(message *agentv1.AgentClientMessage) bool {
|
||||
if message == nil || message.GetRunRequest() == nil {
|
||||
return false
|
||||
}
|
||||
action := message.GetRunRequest().GetAction()
|
||||
if action == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := action.GetAction().(*agentv1.ConversationAction_ResumeAction)
|
||||
return ok
|
||||
}
|
||||
|
||||
// BuildClientHistoryEntry 将消息类型与负载摘要拼接成会话历史记录文本。
|
||||
func BuildClientHistoryEntry(kind string, message *agentv1.AgentClientMessage) string {
|
||||
normalizedKind := strings.TrimSpace(kind)
|
||||
if normalizedKind == "" {
|
||||
normalizedKind = "unknown"
|
||||
}
|
||||
|
||||
payload := extractClientMessagePayload(message)
|
||||
summary := summarizePayload(payload)
|
||||
if summary == "" {
|
||||
if normalizedKind == "unknown" {
|
||||
return ""
|
||||
}
|
||||
return normalizedKind
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", normalizedKind, summary)
|
||||
}
|
||||
|
||||
// detectClientMessageKind 判断 oneof message 当前承载的消息分支类型。
|
||||
func detectClientMessageKind(message *agentv1.AgentClientMessage) string {
|
||||
if message == nil || message.GetMessage() == nil {
|
||||
return ""
|
||||
}
|
||||
switch message.GetMessage().(type) {
|
||||
case *agentv1.AgentClientMessage_RunRequest:
|
||||
return "run_request"
|
||||
case *agentv1.AgentClientMessage_PrewarmRequest:
|
||||
return "prewarm_request"
|
||||
case *agentv1.AgentClientMessage_ConversationAction:
|
||||
return "conversation_action"
|
||||
case *agentv1.AgentClientMessage_ExecClientMessage:
|
||||
return "exec_client_message"
|
||||
case *agentv1.AgentClientMessage_InteractionResponse:
|
||||
return "interaction_response"
|
||||
case *agentv1.AgentClientMessage_ExecClientControlMessage:
|
||||
return "exec_client_control_message"
|
||||
case *agentv1.AgentClientMessage_ClientHeartbeat:
|
||||
return "client_heartbeat"
|
||||
case *agentv1.AgentClientMessage_KvClientMessage:
|
||||
return "kv_client_message"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// extractClientMessagePayload 从 oneof 分支中提取原始 bytes 负载。
|
||||
func extractClientMessagePayload(message *agentv1.AgentClientMessage) []byte {
|
||||
if message == nil || message.GetMessage() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch item := message.GetMessage().(type) {
|
||||
case *agentv1.AgentClientMessage_RunRequest:
|
||||
return marshalProtoMessage(item.RunRequest)
|
||||
case *agentv1.AgentClientMessage_PrewarmRequest:
|
||||
return marshalProtoMessage(item.PrewarmRequest)
|
||||
case *agentv1.AgentClientMessage_ConversationAction:
|
||||
return marshalProtoMessage(item.ConversationAction)
|
||||
case *agentv1.AgentClientMessage_ExecClientMessage:
|
||||
return marshalProtoMessage(item.ExecClientMessage)
|
||||
case *agentv1.AgentClientMessage_InteractionResponse:
|
||||
return marshalProtoMessage(item.InteractionResponse)
|
||||
case *agentv1.AgentClientMessage_ExecClientControlMessage:
|
||||
return marshalProtoMessage(item.ExecClientControlMessage)
|
||||
case *agentv1.AgentClientMessage_ClientHeartbeat:
|
||||
return marshalProtoMessage(item.ClientHeartbeat)
|
||||
case *agentv1.AgentClientMessage_KvClientMessage:
|
||||
return marshalProtoMessage(item.KvClientMessage)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// marshalProtoMessage 将 proto 消息重新编码为 bytes,用于调试摘要展示。
|
||||
func marshalProtoMessage(message proto.Message) []byte {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
payload, err := proto.Marshal(message)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// summarizePayload 生成可读摘要:优先文本,无法直接读时回退为 hex 片段。
|
||||
func summarizePayload(payload []byte) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if utf8.Valid(payload) {
|
||||
text := strings.TrimSpace(string(payload))
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.ReplaceAll(text, "\r", " ")
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" {
|
||||
return truncateText(text, 120)
|
||||
}
|
||||
}
|
||||
return "hex:" + truncateText(hex.EncodeToString(payload), 120)
|
||||
}
|
||||
|
||||
// truncateText 按 rune 数截断文本,避免在多字节字符中间截断导致乱码。
|
||||
func truncateText(text string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return text
|
||||
}
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package step 负责 assistant 输出记录的整理、解析与最小构造。
|
||||
package step
|
||||
@@ -0,0 +1,237 @@
|
||||
// recorder.go 实现 pending assistant 输出记录的解析与构造。
|
||||
package step
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultTextPreviewLimit 表示文本摘要允许保留的最大 rune 数。
|
||||
defaultTextPreviewLimit = 120
|
||||
)
|
||||
|
||||
// assistantMessage 表示 `pending_tool_calls` 中常见的 assistant message 结构。
|
||||
type assistantMessage struct {
|
||||
// ID 是 message 级别的标识,当前抓包中常见值为 1。
|
||||
ID string `json:"id,omitempty"`
|
||||
// Role 是当前 message 的角色,当前常见值为 assistant。
|
||||
Role string `json:"role,omitempty"`
|
||||
// Content 保存该 message 的内容块列表。
|
||||
Content []assistantContent `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// assistantContent 表示 assistant message 内的单个内容块。
|
||||
type assistantContent struct {
|
||||
// Type 表示内容块类型,例如 text、reasoning 或 tool-call。
|
||||
Type string `json:"type,omitempty"`
|
||||
// Text 保存文本内容块文本。
|
||||
Text string `json:"text,omitempty"`
|
||||
// ToolCallID 保存工具调用标识。
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
// ToolName 保存工具名称。
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
// Args 保存工具调用参数原文。
|
||||
Args json.RawMessage `json:"args,omitempty"`
|
||||
// Result 保存工具调用结果原文。
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// Recorder 负责解析与构造 pending assistant 输出记录。
|
||||
type Recorder struct {
|
||||
}
|
||||
|
||||
// StepRecorder 定义运行时依赖的 step 记录接口。
|
||||
type StepRecorder interface {
|
||||
// ParsePendingAssistantOutputs 解析一组原始 pending assistant 输出记录。
|
||||
ParsePendingAssistantOutputs(rawValues []string) []runtimecore.PendingAssistantOutput
|
||||
// ParsePendingAssistantOutput 解析单条原始 assistant 输出记录。
|
||||
ParsePendingAssistantOutput(raw string) runtimecore.PendingAssistantOutput
|
||||
// BuildTextAssistantOutput 构造一条只包含文本的 assistant 输出记录。
|
||||
BuildTextAssistantOutput(text string) (string, runtimecore.PendingAssistantOutput, error)
|
||||
// StartAssistantOutput 创建一个新的 assistant 输出构造器。
|
||||
StartAssistantOutput() *AssistantOutputBuilder
|
||||
}
|
||||
|
||||
// NewRecorder 创建 assistant 输出记录整理器。
|
||||
func NewRecorder() *Recorder {
|
||||
return &Recorder{}
|
||||
}
|
||||
|
||||
// AssistantOutputBuilder 表示一条 assistant 输出记录的构造器。
|
||||
type AssistantOutputBuilder struct {
|
||||
// message 保存当前正在构造的原始 assistant message。
|
||||
message assistantMessage
|
||||
}
|
||||
|
||||
// ParsePendingAssistantOutputs 解析一组原始 `pending_tool_calls` 字符串。
|
||||
func (recorder *Recorder) ParsePendingAssistantOutputs(rawValues []string) []runtimecore.PendingAssistantOutput {
|
||||
if len(rawValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
outputs := make([]runtimecore.PendingAssistantOutput, 0, len(rawValues))
|
||||
for _, raw := range rawValues {
|
||||
outputs = append(outputs, recorder.ParsePendingAssistantOutput(raw))
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
// ParsePendingAssistantOutput 解析单条原始 assistant 输出记录。
|
||||
func (recorder *Recorder) ParsePendingAssistantOutput(raw string) runtimecore.PendingAssistantOutput {
|
||||
output := runtimecore.PendingAssistantOutput{
|
||||
RawMessage: strings.TrimSpace(raw),
|
||||
}
|
||||
if output.RawMessage == "" {
|
||||
return output
|
||||
}
|
||||
|
||||
var message assistantMessage
|
||||
if err := json.Unmarshal([]byte(output.RawMessage), &message); err != nil {
|
||||
output.TextPreview = truncateText(output.RawMessage, defaultTextPreviewLimit)
|
||||
return output
|
||||
}
|
||||
|
||||
output.Role = strings.TrimSpace(message.Role)
|
||||
output.ContentKinds = make([]string, 0, len(message.Content))
|
||||
output.ToolCallIDs = make([]string, 0, len(message.Content))
|
||||
output.ToolNames = make([]string, 0, len(message.Content))
|
||||
|
||||
textParts := make([]string, 0, len(message.Content))
|
||||
for _, part := range message.Content {
|
||||
kind := strings.TrimSpace(part.Type)
|
||||
if kind == "" {
|
||||
kind = "unknown"
|
||||
}
|
||||
output.ContentKinds = append(output.ContentKinds, kind)
|
||||
|
||||
if trimmedToolCallID := strings.TrimSpace(part.ToolCallID); trimmedToolCallID != "" {
|
||||
output.ToolCallIDs = append(output.ToolCallIDs, trimmedToolCallID)
|
||||
}
|
||||
if trimmedToolName := strings.TrimSpace(part.ToolName); trimmedToolName != "" {
|
||||
output.ToolNames = append(output.ToolNames, trimmedToolName)
|
||||
}
|
||||
if trimmedText := strings.TrimSpace(part.Text); trimmedText != "" {
|
||||
textParts = append(textParts, trimmedText)
|
||||
}
|
||||
}
|
||||
|
||||
output.TextPreview = truncateText(strings.Join(textParts, "\n"), defaultTextPreviewLimit)
|
||||
return output
|
||||
}
|
||||
|
||||
// BuildTextAssistantOutput 构造一条只包含文本的 assistant 输出记录。
|
||||
func (recorder *Recorder) BuildTextAssistantOutput(text string) (string, runtimecore.PendingAssistantOutput, error) {
|
||||
message := assistantMessage{
|
||||
ID: "1",
|
||||
Role: "assistant",
|
||||
Content: []assistantContent{
|
||||
{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return "", runtimecore.PendingAssistantOutput{}, err
|
||||
}
|
||||
|
||||
raw := string(payload)
|
||||
return raw, recorder.ParsePendingAssistantOutput(raw), nil
|
||||
}
|
||||
|
||||
// StartAssistantOutput 创建一个新的 assistant 输出构造器。
|
||||
func (recorder *Recorder) StartAssistantOutput() *AssistantOutputBuilder {
|
||||
return &AssistantOutputBuilder{
|
||||
message: assistantMessage{
|
||||
ID: "1",
|
||||
Role: "assistant",
|
||||
Content: make([]assistantContent, 0, 4),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AppendTextDelta 追加一段文本内容。
|
||||
func (builder *AssistantOutputBuilder) AppendTextDelta(text string) {
|
||||
if builder == nil {
|
||||
return
|
||||
}
|
||||
builder.message.Content = append(builder.message.Content, assistantContent{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
// AppendReasoningDelta 追加一段推理内容,供 reasoning 模型在续跑时回放。
|
||||
func (builder *AssistantOutputBuilder) AppendReasoningDelta(text string) {
|
||||
if builder == nil {
|
||||
return
|
||||
}
|
||||
builder.message.Content = append(builder.message.Content, assistantContent{
|
||||
Type: "reasoning",
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
// OpenToolCall 追加一个尚未完成的工具调用块。
|
||||
func (builder *AssistantOutputBuilder) OpenToolCall(toolCall runtimecore.ToolInvocation) {
|
||||
if builder == nil {
|
||||
return
|
||||
}
|
||||
builder.message.Content = append(builder.message.Content, assistantContent{
|
||||
Type: "tool-call",
|
||||
ToolCallID: strings.TrimSpace(toolCall.CallID),
|
||||
ToolName: strings.TrimSpace(toolCall.ToolName),
|
||||
Args: append(json.RawMessage(nil), toolCall.ArgsJSON...),
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteToolCall 为指定工具调用补充结果内容。
|
||||
func (builder *AssistantOutputBuilder) CompleteToolCall(toolCallID string, resultJSON []byte) {
|
||||
if builder == nil {
|
||||
return
|
||||
}
|
||||
for index := range builder.message.Content {
|
||||
if builder.message.Content[index].Type != "tool-call" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(builder.message.Content[index].ToolCallID) != strings.TrimSpace(toolCallID) {
|
||||
continue
|
||||
}
|
||||
builder.message.Content[index].Result = append(json.RawMessage(nil), resultJSON...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotRaw 输出当前 builder 的原始 JSON 和解析结果。
|
||||
func (builder *AssistantOutputBuilder) SnapshotRaw(recorder *Recorder) (string, runtimecore.PendingAssistantOutput, error) {
|
||||
if builder == nil {
|
||||
return "", runtimecore.PendingAssistantOutput{}, nil
|
||||
}
|
||||
payload, err := json.Marshal(builder.message)
|
||||
if err != nil {
|
||||
return "", runtimecore.PendingAssistantOutput{}, err
|
||||
}
|
||||
raw := string(payload)
|
||||
if recorder == nil {
|
||||
recorder = NewRecorder()
|
||||
}
|
||||
return raw, recorder.ParsePendingAssistantOutput(raw), nil
|
||||
}
|
||||
|
||||
// truncateText 按 rune 数截断文本,避免在多字节字符中间截断。
|
||||
func truncateText(text string, maxRunes int) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if maxRunes <= 0 || trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= maxRunes {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user