This commit is contained in:
leokun
2026-06-30 10:38:52 +08:00
commit c083be5ec2
312 changed files with 146628 additions and 0 deletions
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
+237
View File
@@ -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
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package runtimecore 定义 runtime/loop、checkpoint、session 之间共享的状态与事件模型。
package runtimecore
+110
View File
@@ -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
}
+370
View File
@@ -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)
}
+399
View File
@@ -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)
}
+205
View File
@@ -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
}
+2
View File
@@ -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
}
+49
View File
@@ -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 ""
}
+419
View File
@@ -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 ""
}
}
+133
View File
@@ -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]
}
+243
View File
@@ -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"`
}
+2
View File
@@ -0,0 +1,2 @@
// Package promptengine 负责把静态 prompt 资产、会话状态与外部结果编译成模型请求输入。
package promptengine
File diff suppressed because it is too large Load Diff
+518
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package protocol 负责协议层解码、摘要与上行消息种类判断。
package protocol
+224
View File
@@ -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]) + "..."
}
+2
View File
@@ -0,0 +1,2 @@
// Package step 负责 assistant 输出记录的整理、解析与最小构造。
package step
+237
View File
@@ -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]) + "..."
}