Files
ttstd-infinite-canvas/backend/internal/app/provider_text_agent.go

453 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Agent 模型步的上游请求:OpenAI Responses / Chat Completions / Claude 三种协议的工具调用请求体、
// 工具选择兼容降级与非流式响应解析。
//
// 部分上游不支持 tool_choice=required 等写法,遇到兼容性错误时退回 auto 重试一次。
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
)
func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
// 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议,
// 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。
if input.AgentRequests != nil && input.AgentRequests.Canonical != nil {
_, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType)
requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative)
if err != nil {
return nil, err
}
input.AgentRequests = requests
}
if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeAgentTask(ctx, input, adapter)
}
if input.AgentRequests == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
request := input.AgentRequests.ChatCompletion
path := "/chat/completions"
protocol := "chat-completion"
if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) {
request = input.AgentRequests.Responses
path = "/responses"
protocol = "responses"
} else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) {
if input.AgentRequests.Claude != nil {
request = input.AgentRequests.Claude
}
path = "/messages"
protocol = "claude-api"
}
if request == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
body := cloneStringAnyMap(request)
if protocol == "claude-api" && input.AgentRequests.Claude == nil {
body = claudeAgentBody(body)
}
body["model"] = input.Config.Model
applyTextThinking(body, input, protocol)
applyAgentOutputLimit(body, agentStepOutputLimit(input), protocol)
normalizeAgentToolChoice(body, input, protocol)
result, err := postAgentRequest(ctx, input, path, body, protocol)
if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) {
if !isAutoAgentToolChoice(body["tool_choice"]) {
autoBody := cloneStringAnyMap(body)
autoBody["tool_choice"] = "auto"
result, err = postAgentRequest(ctx, input, path, autoBody, protocol)
}
if isAgentToolChoiceCompatibilityError(err) {
withoutToolChoice := cloneStringAnyMap(body)
delete(withoutToolChoice, "tool_choice")
result, err = postAgentRequest(ctx, input, path, withoutToolChoice, protocol)
}
}
if err != nil {
return nil, err
}
return result, nil
}
func postAgentRequest(ctx context.Context, input canvasGenerationInput, path string, body map[string]interface{}, protocol string) (map[string]interface{}, error) {
if input.StreamText {
return postStreamingAgent(ctx, input.Config, path, body, protocol, input.OnTextDelta, input.OnReasoningDelta)
}
delete(body, "stream")
var payload map[string]interface{}
if err := postJSON(ctx, input.Config, path, body, &payload); err != nil {
return nil, err
}
return parseAgentToolPayload(payload, protocol)
}
// agentStepOutputLimit 取本次 Agent 调用的输出上限。
//
// 两个来源语义相同但通道不同:textOptions.maxOutputTokens 是任务信封里下发的策略值
// (画布 Agent 每步按运行时策略给,persist 在任务输入里,重启后仍在);input.MaxOutputTokens
// 是进程内直传的旧入口(渠道模型能力声明)。都为 0 时不写上限字段,交给上游按剩余上下文放行。
// 都非零时取较小值:策略上限不该超过模型自己声明的物理上限。
func agentStepOutputLimit(input canvasGenerationInput) int {
limits := []int{input.TextOptions.MaxOutputTokens, input.MaxOutputTokens}
limit := 0
for _, candidate := range limits {
if candidate <= 0 {
continue
}
if limit == 0 || candidate < limit {
limit = candidate
}
}
return limit
}
// applyAgentOutputLimit 按协议写入输出上限字段名:Claude 与 Chat Completions 用 max_tokens,
// Responses 用 max_output_tokens。
func applyAgentOutputLimit(body map[string]interface{}, limit int, protocol string) {
if limit <= 0 {
return
}
field := "max_tokens"
if protocol == "responses" {
field = "max_output_tokens"
}
applyTextOutputLimit(body, limit, field)
}
func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, adapter protocol.AgentAdapter) (map[string]interface{}, error) {
wire := input.Config.InterfaceType
if wire == string(model.ChannelInterfaceOpenAIResponse) {
wire = "responses"
}
knownWire := wire == "chat-completion" || wire == "responses" || wire == "claude-api"
if input.TextOptions.Thinking && !knownWire {
return nil, errors.New("当前声明式 Agent 渠道尚不支持思考模式,请关闭思考模式或切换内置协议渠道")
}
if input.AgentRequests == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
request := map[string]any{
"chatCompletion": input.AgentRequests.ChatCompletion,
"responses": input.AgentRequests.Responses,
"claude": input.AgentRequests.Claude,
"gemini": input.AgentRequests.Gemini,
}
spec, err := adapter.BuildAgent(ctx, protocol.AgentRequestContext{BaseURL: input.Config.BaseURL, Model: input.Config.Model, Request: request})
if err != nil {
return nil, err
}
if knownWire {
body := protocolBodyObject(spec.Body)
if body == nil {
return nil, errors.New("声明式 Agent 请求体必须是 JSON 对象")
}
applyTextThinking(body, input, wire)
applyAgentOutputLimit(body, agentStepOutputLimit(input), wire)
normalizeAgentToolChoice(body, input, wire)
spec.Body = body
if input.StreamText {
body["stream"] = true
if wire == "chat-completion" {
if err := ensureChatCompletionStreamUsage(body); err != nil {
return nil, err
}
}
parser := newStreamingAgentParser(wire, input.OnTextDelta)
parser.emitReasoning = input.OnReasoningDelta
data, mime, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume)
if err != nil {
return nil, err
}
if strings.Contains(strings.ToLower(mime), "event-stream") {
parser.flush()
return parser.result()
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err)
}
return parseAgentToolPayload(payload, wire)
}
}
body, err := executeDeclarativeAgentWithGeminiCache(ctx, input, spec)
if err != nil {
return nil, err
}
parsed, err := adapter.ParseAgent(ctx, body)
if err != nil {
return nil, err
}
result := map[string]interface{}{"mode": "text", "text": parsed.Text, "toolCalls": []interface{}{}}
if parsed.Reasoning != "" {
result["reasoning"] = parsed.Reasoning
}
calls := make([]interface{}, 0, len(parsed.ToolCalls))
for _, call := range parsed.ToolCalls {
mapped := map[string]interface{}{
"id": call.ID,
"type": "function",
"function": map[string]interface{}{"name": call.Name, "arguments": call.Arguments},
}
if call.ThoughtSignature != "" {
mapped["thoughtSignature"] = call.ThoughtSignature
}
calls = append(calls, mapped)
}
result["toolCalls"] = calls
if strings.TrimSpace(parsed.Text) == "" && len(calls) == 0 {
return nil, errors.New("声明式 Agent 接口没有返回内容")
}
return result, nil
}
// executeDeclarativeAgentWithGeminiCache keeps explicit Prompt Cache entirely
// optional: cache creation, cleanup, and a single stale-cache rebuild can never
// turn a valid uncached Agent request into a failed run.
func executeDeclarativeAgentWithGeminiCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, error) {
baseSpec, err := cloneProtocolRequestSpec(spec)
if err != nil {
return nil, err
}
executeSpec := baseSpec
cacheUsed := false
if input.Config.InterfaceType == officialGeminiAgentInterface {
prepared, used, prepareErr := prepareOfficialGeminiAgentCache(ctx, input, baseSpec)
if prepareErr != nil {
log.Printf("gemini agent prompt cache unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr))
} else {
executeSpec, cacheUsed = prepared, used
}
}
body, err := executeProtocolRequest(ctx, input.Config, executeSpec)
if err == nil || input.Config.InterfaceType != officialGeminiAgentInterface || !cacheUsed || !geminiRequestUsesCachedContent(executeSpec) || !isGeminiCachedContentNotFound(err, stringValue(protocolBodyObject(executeSpec.Body)["cachedContent"])) {
return body, err
}
resourceName := ""
if body := protocolBodyObject(executeSpec.Body); body != nil {
resourceName, _ = body["cachedContent"].(string)
}
if invalidateErr := invalidateOfficialGeminiAgentCache(ctx, input, baseSpec, resourceName); invalidateErr != nil {
// The local identity is still removed whenever possible. Do not replace a
// provider 404 with a cache bookkeeping error or skip the one rebuild.
log.Printf("gemini agent prompt cache invalidation failed: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(invalidateErr))
}
rebuilt, _, prepareErr := prepareOfficialGeminiAgentCacheMode(ctx, input, baseSpec, true)
if prepareErr != nil {
log.Printf("gemini agent prompt cache rebuild unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr))
rebuilt = baseSpec
}
// The rebuilt request is intentionally executed only once. If it receives
// another CachedContent 404, the first stale-cache recovery already happened;
// preserve the provider error instead of recursively rebuilding forever.
return executeProtocolRequest(ctx, input.Config, rebuilt)
}
func claudeAgentBody(request map[string]interface{}) map[string]interface{} {
body := map[string]interface{}{"max_tokens": 4096}
if messages, ok := request["messages"].([]interface{}); ok {
claudeMessages := make([]interface{}, 0, len(messages))
var system []string
for _, value := range messages {
message, _ := value.(map[string]interface{})
role := strings.ToLower(strings.TrimSpace(stringField(message, "role")))
if role == "system" {
if content := strings.TrimSpace(fmt.Sprint(message["content"])); content != "" {
system = append(system, content)
}
continue
}
if role == "tool" {
claudeMessages = append(claudeMessages, map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{
"type": "tool_result", "tool_use_id": stringField(message, "tool_call_id"), "content": fmt.Sprint(message["content"]),
}}})
continue
}
role = mapClaudeMessageRole(role)
content := message["content"]
if content == nil {
content = ""
}
if toolCalls, ok := message["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 {
blocks := make([]interface{}, 0, len(toolCalls))
for _, value := range toolCalls {
toolCall, _ := value.(map[string]interface{})
function, _ := toolCall["function"].(map[string]interface{})
blocks = append(blocks, map[string]interface{}{
"type": "tool_use", "id": stringField(toolCall, "id"), "name": stringField(function, "name"), "input": claudeToolInput(function["arguments"]),
})
}
content = blocks
}
claudeMessages = append(claudeMessages, map[string]interface{}{"role": role, "content": content})
}
body["messages"] = claudeMessages
if len(system) > 0 {
body["system"] = []interface{}{map[string]interface{}{
"type": "text", "text": strings.Join(system, "\n\n"),
"cache_control": map[string]interface{}{"type": "ephemeral"},
}}
}
}
if tools, ok := request["tools"].([]interface{}); ok && len(tools) > 0 {
claudeTools := make([]interface{}, 0, len(tools))
for _, value := range tools {
tool, _ := value.(map[string]interface{})
function, _ := tool["function"].(map[string]interface{})
if len(function) == 0 {
continue
}
claudeTools = append(claudeTools, map[string]interface{}{
"name": stringField(function, "name"), "description": stringField(function, "description"), "input_schema": function["parameters"],
})
}
if len(claudeTools) > 0 {
if last, ok := claudeTools[len(claudeTools)-1].(map[string]interface{}); ok {
last["cache_control"] = map[string]interface{}{"type": "ephemeral"}
}
body["tools"] = claudeTools
}
}
if choice, ok := request["tool_choice"]; ok {
body["tool_choice"] = claudeToolChoice(choice)
}
return body
}
func mapClaudeMessageRole(role string) string {
if role == "assistant" {
return "assistant"
}
return "user"
}
func claudeToolInput(value interface{}) interface{} {
if raw, ok := value.(string); ok {
var parsed interface{}
if json.Unmarshal([]byte(raw), &parsed) == nil && parsed != nil {
return parsed
}
}
if value != nil {
return value
}
return map[string]interface{}{}
}
func claudeToolChoice(value interface{}) interface{} {
switch choice := value.(type) {
case string:
switch strings.ToLower(strings.TrimSpace(choice)) {
case "required":
return map[string]interface{}{"type": "any"}
case "none":
return map[string]interface{}{"type": "auto"}
default:
return map[string]interface{}{"type": "auto"}
}
case map[string]interface{}:
if function, ok := choice["function"].(map[string]interface{}); ok && stringField(function, "name") != "" {
return map[string]interface{}{"type": "tool", "name": stringField(function, "name")}
}
if name := stringField(choice, "name"); name != "" {
return map[string]interface{}{"type": "tool", "name": name}
}
}
return map[string]interface{}{"type": "auto"}
}
func isAgentToolChoiceCompatibilityError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
var payloadErr providerPayloadError
if errors.As(err, &payloadErr) {
message += " " + strings.ToLower(payloadErr.raw)
}
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
message += " " + strings.ToLower(httpErr.Body)
}
return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode")
}
func isAutoAgentToolChoice(value interface{}) bool {
choice, ok := value.(string)
return ok && strings.EqualFold(strings.TrimSpace(choice), "auto")
}
func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) {
if err := validateTextPayload(payload); err != nil {
return nil, err
}
result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}}
if protocol == "responses" {
result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload))
if reasoning := extractResponseReasoning(payload); reasoning != "" {
result["reasoning"] = reasoning
}
calls := make([]interface{}, 0)
for _, value := range interfaceSlice(payload["output"]) {
item, _ := value.(map[string]interface{})
if stringField(item, "type") != "function_call" {
continue
}
calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}})
}
result["toolCalls"] = calls
return result, nil
}
if protocol == "claude-api" {
content := interfaceSlice(payload["content"])
calls := make([]interface{}, 0)
for _, value := range content {
item, _ := value.(map[string]interface{})
switch stringField(item, "type") {
case "text":
result["text"] = result["text"].(string) + stringField(item, "text")
case "thinking":
result["reasoning"] = resultString(result, "reasoning") + firstNonEmptyString(stringField(item, "thinking"), stringField(item, "text"))
case "tool_use":
arguments, err := json.Marshal(item["input"])
if err != nil {
return nil, err
}
calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": string(arguments)}})
}
}
result["toolCalls"] = calls
if result["text"] == "" && len(calls) == 0 {
return nil, errors.New("Claude Agent 接口没有返回内容")
}
return result, nil
}
choices := interfaceSlice(payload["choices"])
if len(choices) == 0 {
return nil, errors.New("画布 Agent 接口没有返回 choices")
}
choice, _ := choices[0].(map[string]interface{})
message, _ := choice["message"].(map[string]interface{})
result["text"] = stringField(message, "content")
if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" {
result["reasoning"] = reasoning
}
calls := make([]interface{}, 0)
for _, value := range interfaceSlice(message["tool_calls"]) {
item, _ := value.(map[string]interface{})
function, _ := item["function"].(map[string]interface{})
calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}})
}
result["toolCalls"] = calls
return result, nil
}