Files
Agent-System-Backend/internal/runtime/model.go
T
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see.

Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests.

Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way.

Refs: Agent-System-Dev feature/four-model-vision
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:51:17 +00:00

161 lines
4.7 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.
package runtime
import (
"strings"
"agent-system-backend/internal/vision"
core "agent-system-core"
)
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
var SecretLookup func(owner, service string) (string, bool)
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
func LookupSecret(owner, service, fallback string) string {
return overlayKey(owner, service, fallback)
}
func overlayKey(owner, service, fallback string) string {
if SecretLookup == nil {
return fallback
}
if owner == "" {
owner = "local"
}
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
return v
}
return fallback
}
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
type ContextInjection struct {
Keys []string
Secondary string
Logic string
Content string
Constant bool
ScanDepth int
BudgetChars int
}
// ResolvedModel 是一次路由解析的结果。
type ResolvedModel struct {
Kind string // chat | lightweight
Model string
Base string
Key string
}
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.chat.model", "")
if prof.Model.Chat != "" {
m = prof.Model.Chat
}
return ResolvedModel{Kind: "chat", Model: m,
Base: eff.GetString("models.chat.baseUrl", ""),
Key: overlayKey(prof.Owner, "models.chat", eff.GetString("models.chat.apiKey", ""))}
}
// ResolveVisionModel 在当前轮含图片时核对视觉清单。清单内的模型保持原选择;
// 不在清单内则这一轮改用 models.vision.model,端点和密钥仍沿用主模型。
// 没有图片时与 ResolveChatModel 相同。
func ResolveVisionModel(prof core.AgentProfile, eff core.EffectiveSettings, hasImages bool) ResolvedModel {
chat := ResolveChatModel(prof, eff)
if !hasImages || modelCapable(chat.Model, VisionCapable(eff)) {
return chat
}
name := strings.TrimSpace(eff.GetString("models.vision.model", ""))
if name == "" {
name = vision.DefaultVisionModel
}
if name == chat.Model {
return chat
}
chat.Model = name
return chat
}
// VisionCapable 解析 models.vision.capable。空字符串用内置默认清单。
func VisionCapable(eff core.EffectiveSettings) []string {
raw := strings.TrimSpace(eff.GetString("models.vision.capable", ""))
if raw == "" {
raw = vision.DefaultVisionCapable
}
return splitModelList(raw)
}
func modelCapable(name string, capable []string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, item := range capable {
if item == name {
return true
}
}
return false
}
func splitModelList(raw string) []string {
var out []string
seen := map[string]bool{}
for _, part := range strings.Split(raw, ",") {
name := strings.TrimSpace(part)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// ChatFallbacks 解析 models.chat.fallbacks。默认空,不改变现有模型选择。
func ChatFallbacks(eff core.EffectiveSettings) []string {
return splitModelList(eff.GetString("models.chat.fallbacks", ""))
}
// ChatFallbacksFor 在图片轮只保留视觉清单内的回退模型,避免退到不能看图的模型。
func ChatFallbacksFor(eff core.EffectiveSettings, hasImages bool) []string {
all := ChatFallbacks(eff)
if !hasImages {
return all
}
capable := VisionCapable(eff)
var out []string
for _, name := range all {
if modelCapable(name, capable) {
out = append(out, name)
}
}
return out
}
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.lightweight.model", "")
base := eff.GetString("models.lightweight.baseUrl", "")
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
if prof.Model.Lightweight != "" {
m = prof.Model.Lightweight
}
if m == "" || base == "" {
// 回落主档(模型与端点分别回落,允许轻量仅覆盖模型名)。
chat := ResolveChatModel(prof, eff)
if m == "" {
m = chat.Model
}
if base == "" {
base, key = chat.Base, chat.Key
}
}
return ResolvedModel{Kind: "lightweight", Model: m, Base: base, Key: key}
}