From fa33c79e119c0411f40297126a5371e639d60adb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:44:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(model,capacity,vision,io):=20per-persona?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=20+=20=E6=9C=AC=E5=9C=B0=E4=B8=89?= =?UTF-8?q?=E7=BA=A7(=E9=BB=98=E8=AE=A4=E5=85=B3)=20+=20L3=20=E5=88=86?= =?UTF-8?q?=E7=B1=BB=20+=20OCR=20=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9E=8B=20+?= =?UTF-8?q?=20image-crop=20=E5=B7=A5=E5=85=B7=EF=BC=88=E8=A3=81=E5=86=B3?= =?UTF-8?q?=2003.4.1/03.4.3/14.4.1/14.4.4/14.4.5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()), Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型 - 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源 - 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false** - 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具; vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威) - 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切 - 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行) - 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异) - 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面) --- cmd/cyrene/wire_capacity.go | 99 +++++++ cmd/cyrene/wire_runtime.go | 69 +---- internal/config/config.go | 53 +++- internal/config/runtimeconfig/models.go | 86 +++++- internal/runtime/capacity/capacity.go | 21 +- internal/runtime/capacity/classifier.go | 129 ++++++++ internal/runtime/capacity/l3.go | 134 +++++++++ internal/runtime/capacity/l3_test.go | 203 +++++++++++++ internal/runtime/capacity/signals.go | 52 +++- internal/runtime/loop/request.go | 13 + internal/runtime/model/factory.go | 68 ++++- internal/runtime/model/fallback.go | 21 +- internal/runtime/model/local_rung_test.go | 187 ++++++++++++ internal/runtime/model/persona_router_test.go | 119 ++++++++ internal/runtime/model/router.go | 109 ++++++- internal/runtime/model/runtime.go | 43 ++- internal/runtime/multimodal/vision/adapter.go | 50 +++- .../multimodal/vision/ocr_model_test.go | 89 ++++++ internal/runtime/multimodal/vision/router.go | 11 + plugins/tools/io/crop.go | 278 ++++++++++++++++++ plugins/tools/io/crop_test.go | 205 +++++++++++++ plugins/tools/io/doc.go | 10 +- plugins/tools/io/tools.go | 4 +- plugins/tools/io/tools_test.go | 10 +- 24 files changed, 1941 insertions(+), 122 deletions(-) create mode 100644 cmd/cyrene/wire_capacity.go create mode 100644 internal/runtime/capacity/classifier.go create mode 100644 internal/runtime/capacity/l3.go create mode 100644 internal/runtime/capacity/l3_test.go create mode 100644 internal/runtime/model/local_rung_test.go create mode 100644 internal/runtime/model/persona_router_test.go create mode 100644 internal/runtime/multimodal/vision/ocr_model_test.go create mode 100644 plugins/tools/io/crop.go create mode 100644 plugins/tools/io/crop_test.go diff --git a/cmd/cyrene/wire_capacity.go b/cmd/cyrene/wire_capacity.go new file mode 100644 index 0000000..a50c92e --- /dev/null +++ b/cmd/cyrene/wire_capacity.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" +) + +// 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、 +// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出 +// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。 + +// buildCapacity constructs the awakened-capacity decider when enabled, or nil +// (which keeps the loop's default all-tools behavior). It is opt-in via +// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and +// falls back to built-in defaults on a parse error. +// +// The model-assisted L3 layer (裁决 14.4.4) is a second, independent switch +// (CAPACITY_L3_ENABLED, default false): even with capacity on, L3 stays silent +// unless it was explicitly asked for, and its verdict cache TTL +// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader +// only carries scalars. +func (a *app) buildCapacity() *capacity.Decider { + if !a.cfg.Capacity.Enabled { + return nil + } + sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON) + if err != nil { + a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err) + sets = nil + } + cfg := capacity.Config{ + Enabled: true, + MinConfidence: a.cfg.Capacity.MinConfidence, + ToolSets: sets, + } + if a.cfg.Capacity.L3Enabled { + cfg.L3Enabled = true + cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log) + cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log) + } + return capacity.New(cfg, capacity.WithLogger(a.log)) +} + +// parseL3CacheTTL parses the CAPACITY_L3_CACHE_TTL duration string. An empty +// value selects the code default (5m, the landing suggestion of 裁决 14.4.4); an +// unparsable value is reported and falls back to the same default rather than +// silently disabling the cache. +func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration { + raw = strings.TrimSpace(raw) + if raw == "" { + return capacity.DefaultL3CacheTTL + } + d, err := time.ParseDuration(raw) + if err != nil { + logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default", + "value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err) + return capacity.DefaultL3CacheTTL + } + return d +} + +// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of +// state→allowed tool name list. A blank string yields nil (built-in defaults); a +// null value for a state means unrestricted (all tools). +func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var m map[string][]string + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, fmt.Errorf("capacity tool sets: %w", err) + } + out := make(map[capacity.State][]string, len(m)) + for k, v := range m { + out[capacity.State(k)] = v + } + return out, nil +} + +// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken +// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` +// frame. The loop only emits on a real state transition (never every round), and +// the event carries the originating session id so it routes to that session, not +// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}. +func (a *app) onCapacity(ev capacity.Event) { + logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", + "type", ev.Type, "from", ev.FromState, "to", ev.ToState, + "tool", ev.Tool, "at", ev.At, "session", ev.SessionID) + if a.capacitySink == nil { + return + } + a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool) +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 3f605b9..b62e3d8 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "path/filepath" "strings" @@ -15,7 +14,6 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" - "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" @@ -191,8 +189,14 @@ func (a *app) wireRuntime() error { } // Vision routing: the loop rewrites an image-bearing user message per the - // vision Router — a vision-capable main model sees the image directly, - // otherwise the vision/OCR tool's text description replaces it (design/06 §0). + // vision Router — a main model that really accepts image input sees the image + // directly, otherwise the vision/OCR tool's text description replaces it + // (design/06 §0)。 + // + // SupportsVision means "主模型能否收图"(裁决 14.4.1 修语义,C-7), read from the + // active models.json group's `multimodal` declaration — NOT from + // LLM_VISION_MODEL, which configures the vision *tool's* model. It is read per + // call so a panel save/reload takes effect on the next turn. visionRouter := vision.NewRouter( vision.WithVisionTool(visionToolName), vision.WithOCRTool(ocrToolName), @@ -200,7 +204,8 @@ func (a *app) wireRuntime() error { visionResolver := vision.NewMessageResolver( visionRouter, func() vision.ModelInfo { - return vision.ModelInfo{Name: a.cfg.LLM.VisionModel, SupportsVision: a.cfg.LLM.VisionModel != ""} + group := a.llm.Group() + return vision.ModelInfo{Name: group.Model, SupportsVision: group.Multimodal} }, a.dispatcher.Invoke, ) @@ -374,40 +379,8 @@ func (a *app) approvalActor(ctx context.Context) string { return pid } -// buildCapacity constructs the awakened-capacity decider when enabled, or nil -// (which keeps the loop's default all-tools behavior). It is opt-in via -// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and -// falls back to built-in defaults on a parse error. -func (a *app) buildCapacity() *capacity.Decider { - if !a.cfg.Capacity.Enabled { - return nil - } - sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON) - if err != nil { - a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err) - sets = nil - } - return capacity.New(capacity.Config{ - Enabled: true, - MinConfidence: a.cfg.Capacity.MinConfidence, - ToolSets: sets, - }, capacity.WithLogger(a.log)) -} - -// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken -// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` -// frame. The loop only emits on a real state transition (never every round), and -// the event carries the originating session id so it routes to that session, not -// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}. -func (a *app) onCapacity(ev capacity.Event) { - logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", - "type", ev.Type, "from", ev.FromState, "to", ev.ToState, - "tool", ev.Tool, "at", ev.At, "session", ev.SessionID) - if a.capacitySink == nil { - return - } - a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool) -} +// buildCapacity / onCapacity / capacity JSON+TTL parsing live in +// wire_capacity.go (AGENTS §10 文件 ≤500 行). // onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the // design/11 §3 security gate in a group/stranger environment into the gateway @@ -424,23 +397,7 @@ func (a *app) onToolAudit(ev loop.AuditEvent) { a.gw.AuditEvent(ev.Who, ev.What, ev.Result) } -// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of -// state→allowed tool name list. A blank string yields nil (built-in defaults); a -// null value for a state means unrestricted (all tools). -func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { - if strings.TrimSpace(raw) == "" { - return nil, nil - } - var m map[string][]string - if err := json.Unmarshal([]byte(raw), &m); err != nil { - return nil, fmt.Errorf("capacity tool sets: %w", err) - } - out := make(map[capacity.State][]string, len(m)) - for k, v := range m { - out[capacity.State(k)] = v - } - return out, nil -} +// parseCapacityToolSets / parseL3CacheTTL live in wire_capacity.go. // dbCfgForEmbed returns the DB config used by the vector index, which opens the // same SQLite file as the primary pool. diff --git a/internal/config/config.go b/internal/config/config.go index 4d4c0c0..5ef3cab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,10 +88,23 @@ type LLMConfig struct { Model string `env:"LLM_MODEL"` Fallback string `env:"LLM_FALLBACK_MODEL"` - // VisionModel is the dedicated model used for image/vision requests. When - // set, the model router routes image content to it (see model.Router.WithVisionModel). + // VisionModel is the dedicated model behind the vision tool (image + // understanding). It is the tool's model, not the main model's ability: the + // per-persona/per-request routing decision belongs to the vision Router, and + // the main model's own image input capability is declared by the active + // models.json group's multimodal flag (裁决 14.4.1/14.4.5)。 VisionModel string `env:"LLM_VISION_MODEL"` + // OCRModel is the dedicated model behind the OCR tool. Empty reuses + // VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1 + // 的「独立视觉模型 + OCR 模型」)。 + OCRModel string `env:"LLM_OCR_MODEL"` + + // Local is the optional third degradation level (裁决 03.4.3): primary → + // fallback → local. It defaults to off because the current machine cannot run + // a local model comfortably; when off the chain keeps its two-level shape. + Local LocalLLMConfig + // FallbackMaxTokens caps the last-resort fallback model in a group's chain // (primary → fallback). It is the final safety budget: it only applies when // the chain actually has a fallback step, that group sets no per-group @@ -107,6 +120,33 @@ type LLMConfig struct { AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"` } +// LocalLLMConfig configures the optional local model rung — the third level of +// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in +// and defaults to off: a machine that cannot run a local model comfortably must +// not silently pay for one. It becomes an active chain step only when Enabled is +// true and both BaseURL and Model are set; anything less is reported and skipped +// rather than producing a half-configured step. +type LocalLLMConfig struct { + // Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关). + Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"` + // Provider is the local endpoint's wire protocol: "openai" (llama.cpp / + // ollama / vLLM compatible endpoints) or "anthropic". Empty means openai. + Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"` + // BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1). + BaseURL string `env:"LLM_LOCAL_BASE_URL"` + // APIKey is the local endpoint's key; empty is normal for a local server. + APIKey string `env:"LLM_LOCAL_API_KEY"` + // Model is the local model name. Without it the rung stays off. + Model string `env:"LLM_LOCAL_MODEL"` +} + +// Configured reports whether the local rung has enough configuration to act as a +// chain step. Enabled alone is not enough: a missing endpoint or model must not +// become a step that can only fail. +func (l LocalLLMConfig) Configured() bool { + return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != "" +} + // EmbeddingConfig holds the optional semantic embedding API settings. When // unset, the runtime falls back to built-in n-gram/FTS vectors. type EmbeddingConfig struct { @@ -246,6 +286,15 @@ type CapacityConfig struct { // reflection-based loader only supports scalars and []string), matching the // MCP config pattern. ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"` + // L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3; + // 裁决 14.4.4, keys chosen as the landing suggestion the owner authorized). + // It costs one extra model call for an undecided turn, so it defaults to off: + // off means the layer never runs and L1/L2/L4 behave exactly as before. + L3Enabled bool `env:"CAPACITY_L3_ENABLED" default:"false"` + // L3CacheTTL is how long a model-assisted classification of the same turn text + // is reused (a Go duration string such as "5m"). Zero or unparsable disables + // reuse; empty selects the code default of 5m. + L3CacheTTL string `env:"CAPACITY_L3_CACHE_TTL" default:"5m"` } // MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or diff --git a/internal/config/runtimeconfig/models.go b/internal/config/runtimeconfig/models.go index 1a2ad27..34d2b53 100644 --- a/internal/config/runtimeconfig/models.go +++ b/internal/config/runtimeconfig/models.go @@ -47,6 +47,18 @@ type Group struct { // last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and // beyond that the adapter default applies. MaxTokens int `json:"maxTokens,omitempty"` + // PersonaModels binds an individual persona to its own model name within this + // group (裁决 03.4.1: the "role" of per-role routing is the persona a turn + // answers as, not the LLM message role). The carrier is deliberately + // models.json rather than configs/personas/*.yaml, so the panel can edit the + // binding together with the rest of the provider configuration and no persona + // schema change is needed. An unknown persona id falls back to the group's + // default model, so a removed persona never breaks routing. + PersonaModels map[string]string `json:"personaModels,omitempty"` + // PersonaProviders binds an individual persona to another vendor's protocol + // (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry + // without a provider keeps this group's protocol. + PersonaProviders map[string]string `json:"personaProviders,omitempty"` } // ErrInvalidGroup is returned when a group fails schema validation. @@ -75,9 +87,49 @@ func (g Group) Validate() error { if g.MaxTokens < 0 { return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens) } + if err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil { + return err + } + if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil { + return err + } return nil } +// validatePersonaMap checks a persona-keyed binding map: every key must be a +// non-empty persona id and every value a non-empty value. When providers is true +// the values must additionally be a known protocol, so a typo in a per-persona +// provider fails the group loudly instead of silently routing to the default. +func validatePersonaMap(field string, m map[string]string, providers bool) error { + for persona, value := range m { + if strings.TrimSpace(persona) == "" { + return fmt.Errorf("%s: persona id must not be empty", field) + } + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s: value for persona %q must not be empty", field, persona) + } + if providers && value != ProviderOpenAI && value != ProviderAnthropic { + return fmt.Errorf("%s: provider %q for persona %q must be openai or anthropic", field, value, persona) + } + } + return nil +} + +// PersonaRoute returns the per-persona model/provider binding, if one is +// configured for persona. An empty model (or an unknown persona) is reported as +// ok=false so the caller keeps the group's default binding. +func (g Group) PersonaRoute(persona string) (model, provider string, ok bool) { + persona = strings.TrimSpace(persona) + if persona == "" { + return "", "", false + } + model = strings.TrimSpace(g.PersonaModels[persona]) + if model == "" { + return "", "", false + } + return model, strings.TrimSpace(g.PersonaProviders[persona]), true +} + // ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter // factory. The group's provider selects which config block is populated. func (g Group) ToLLMConfig() config.LLMConfig { @@ -161,26 +213,32 @@ func loadFile(path string) ([]Group, error) { // GroupFromEnv synthesizes a single group from the active env LLM block: the // anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible // block. It is the .env fallback used when models.json is absent or invalid. +// +// Multimodal stays false here: the flat env block has no way to declare whether +// the main model accepts image input, and claiming a capability the endpoint may +// not have is worse than routing the image through the vision/OCR tool (which is +// exactly what the tool exists for). The declaration lives on the models.json +// group's `multimodal` flag, which the provider panel writes (design/06 §0 首条; +// 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the +// main model's ability. func GroupFromEnv(env config.LLMConfig) []Group { if env.Provider == ProviderAnthropic { return []Group{{ - ID: ProviderAnthropic, - Provider: ProviderAnthropic, - BaseURL: env.AnthropicBaseURL, - APIKey: env.AnthropicAPIKey, - Model: env.AnthropicModel, - Fallback: env.AnthropicFallback, - Multimodal: env.VisionModel != "", + ID: ProviderAnthropic, + Provider: ProviderAnthropic, + BaseURL: env.AnthropicBaseURL, + APIKey: env.AnthropicAPIKey, + Model: env.AnthropicModel, + Fallback: env.AnthropicFallback, }} } return []Group{{ - ID: ProviderOpenAI, - Provider: ProviderOpenAI, - BaseURL: env.BaseURL, - APIKey: env.APIKey, - Model: env.Model, - Fallback: env.Fallback, - Multimodal: env.VisionModel != "", + ID: ProviderOpenAI, + Provider: ProviderOpenAI, + BaseURL: env.BaseURL, + APIKey: env.APIKey, + Model: env.Model, + Fallback: env.Fallback, }} } diff --git a/internal/runtime/capacity/capacity.go b/internal/runtime/capacity/capacity.go index 1fd53c4..7e61a0a 100644 --- a/internal/runtime/capacity/capacity.go +++ b/internal/runtime/capacity/capacity.go @@ -110,6 +110,17 @@ type Config struct { // Awakening carries the persona-level override (design §3). Zero = no // override. Awakening Awakening + // L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3; + // 裁决 14.4.4). It is off by default: the layer costs one model call per + // undecided turn, so it must be asked for explicitly. When false the + // classifier is never consulted, whatever L3Classifier holds. + L3Enabled bool + // L3Classifier is the model-assisted classifier the L3 layer consults when + // L3Enabled is true. Nil keeps the layer silent. + L3Classifier IntentClassifier + // L3CacheTTL is how long a verdict for identical turn text is reused (裁决 + // 14.4.4 的 L3 输出缓存). Zero or negative disables reuse. + L3CacheTTL time.Duration } // Input is everything Decide needs to classify one round (design §4). @@ -140,10 +151,13 @@ type Input struct { type Option func(*Decider) // Decider is the awakened-capacity decision engine. It is immutable after New -// and safe for concurrent Decide calls. +// and safe for concurrent Decide calls (the L3 cache is mutex-guarded). type Decider struct { cfg Config log *slog.Logger + // l3cache is the model-assisted intent layer's classifier + verdict cache. + // Nil when the layer is off, which is the default (裁决 14.4.4). + l3cache *l3Cache } // sourcedLog returns a logger tagged with the module source (project rule §10), @@ -175,6 +189,9 @@ func New(cfg Config, opts ...Option) *Decider { } d := &Decider{cfg: cfg} d.log = sourcedLog(nil) + if cfg.L3Enabled { + d.l3cache = newL3Cache(cfg.L3Classifier, cfg.L3CacheTTL) + } for _, opt := range opts { if opt != nil { opt(d) @@ -196,7 +213,7 @@ func (d *Decider) Decide(ctx context.Context, in Input) Decision { if !d.cfg.Enabled { return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)} } - state, conf, tool := d.intent(in) + state, conf, tool := d.intent(ctx, in) j := judgmentFor(in, state, conf, tool) state, conf = d.applyAwakening(state, conf, in) allowed := d.allowedTools(state, in.AllToolNames) diff --git a/internal/runtime/capacity/classifier.go b/internal/runtime/capacity/classifier.go new file mode 100644 index 0000000..8cbda3e --- /dev/null +++ b/internal/runtime/capacity/classifier.go @@ -0,0 +1,129 @@ +package capacity + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// l3MaxTokens caps the classifier's reply: it is a tiny JSON object, and an +// uncapped reply would burn budget on prose the parser then rejects. +const l3MaxTokens = 128 + +// l3SystemPrompt instructs the model to answer with a strict JSON judgment only. +// It deliberately repeats "JSON only": a prose answer is an error, not a verdict. +const l3SystemPrompt = `你是意图分类器。判断用户这一轮是在「办事」(有明确任务动作/对象),还是在「陪伴/闲聊」。 +只输出一个 JSON 对象,不要任何解释或代码块标记,格式: +{"isTask": true|false, "taskKind": "complex"|"code"|"query"|"ops", "confidence": 0.0-1.0} +taskKind 只在 isTask 为 true 时有意义;拿不准时把 confidence 给低(<0.6)。` + +// l3WireJudgment is the wire shape the classifier parses. It is separate from +// Judgment so an unexpected field can never leak into the decision engine. +type l3WireJudgment struct { + IsTask bool `json:"isTask"` + TaskKind string `json:"taskKind"` + Confidence float64 `json:"confidence"` +} + +// LLMClassifier is the model-assisted intent classifier behind the L3 layer +// (design/14 §2.2 L3; 裁决 14.4.4). It asks the adapter for a strict JSON +// judgment and returns it; an unparsable or empty reply is an error, never a +// guess, so the caller keeps its conservative default. It is safe for concurrent +// use. +type LLMClassifier struct { + adapter model.Adapter + log *slog.Logger +} + +// NewLLMClassifier builds the classifier over the runtime's LLM stack. A nil +// adapter is allowed and makes Classify report ErrNotConfigured. +func NewLLMClassifier(adapter model.Adapter, log *slog.Logger) *LLMClassifier { + if log == nil { + log = slog.New(slog.NewTextHandler(discard{}, nil)) + } + return &LLMClassifier{adapter: adapter, log: logger.WithSource(log, "runtime/capacity")} +} + +// ErrNotConfigured is returned when the classifier has no LLM stack to ask. +var ErrNotConfigured = fmt.Errorf("capacity: L3 classifier has no model configured") + +// Classify asks the model for one judgment of text. It returns the raw verdict; +// callers apply the confidence threshold (see Decider.l3). +func (c *LLMClassifier) Classify(ctx context.Context, text string) (Judgment, error) { + if strings.TrimSpace(text) == "" { + return Judgment{}, ErrEmptyTurn + } + if c.adapter == nil { + return Judgment{}, ErrNotConfigured + } + resp, err := c.adapter.Chat(ctx, model.Request{ + Messages: []model.Message{ + model.TextMessage(model.RoleSystem, l3SystemPrompt), + model.TextMessage(model.RoleUser, text), + }, + MaxTokens: l3MaxTokens, + Purpose: "capacity-l3", + }, model.Options{}) + if err != nil { + return Judgment{}, fmt.Errorf("capacity: L3 classify: %w", err) + } + wire, err := parseL3Judgment(resp.Content) + if err != nil { + return Judgment{}, err + } + c.log.Debug("capacity L3 verdict", + "is_task", wire.IsTask, "task_kind", strings.TrimSpace(wire.TaskKind), + "confidence", wire.Confidence) + return Judgment{ + IsTask: wire.IsTask, + TaskKind: strings.TrimSpace(wire.TaskKind), + Confidence: clampUnit(wire.Confidence), + }, nil +} + +// parseL3Judgment extracts the JSON object from a model reply. A reply wrapped in +// a ```json fence is tolerated (models add one routinely), but prose around the +// object is not: the object must be the whole answer. +func parseL3Judgment(content string) (l3WireJudgment, error) { + raw := stripCodeFence(strings.TrimSpace(content)) + if raw == "" { + return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: empty reply", model.ErrBadRequest) + } + var wire l3WireJudgment + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: parse reply: %v", model.ErrBadRequest, err) + } + return wire, nil +} + +// stripCodeFence removes a single ```/```json fence around the payload when the +// whole reply is fenced. +func stripCodeFence(s string) string { + if !strings.HasPrefix(s, "```") { + return s + } + body := strings.TrimPrefix(s, "```") + if i := strings.IndexByte(body, '\n'); i >= 0 { + body = body[i+1:] + } + body = strings.TrimSuffix(body, "```") + return strings.TrimSpace(body) +} + +// clampUnit keeps a model-reported confidence inside [0,1], so a hallucinated +// 7.5 can never outrank the threshold arithmetic. +func clampUnit(v float64) float64 { + switch { + case v < 0: + return 0 + case v > 1: + return 1 + default: + return v + } +} diff --git a/internal/runtime/capacity/l3.go b/internal/runtime/capacity/l3.go new file mode 100644 index 0000000..12e558d --- /dev/null +++ b/internal/runtime/capacity/l3.go @@ -0,0 +1,134 @@ +package capacity + +import ( + "context" + "errors" + "strings" + "sync" + "time" +) + +// IntentClassifier is the model-assisted intent classifier behind the L3 layer +// (design/14 §2.2 L3; 裁决 14.4.4). It is an interface so the decision engine +// stays testable without a model: the wiring supplies the LLM-backed +// implementation (NewLLMClassifier), a test supplies a stub. +// +// Classify returns the raw judgment for one turn's text — {isTask, taskKind, +// confidence} plus nothing else. An error means "no verdict": the caller keeps +// the conservative default rather than guessing. +type IntentClassifier interface { + Classify(ctx context.Context, text string) (Judgment, error) +} + +// maxL3CacheEntries bounds the L3 verdict cache so a long-lived process cannot +// grow it without limit. Past the bound, expired entries are dropped first and +// the cache is cleared if every entry is still fresh. +const maxL3CacheEntries = 256 + +// ErrEmptyTurn is returned when the L3 classifier is asked to judge empty turn +// text. It is a sentinel so callers (and tests) can test the category without +// matching on the message text. +var ErrEmptyTurn = errors.New("capacity: empty turn text for L3 classification") + +// DefaultL3CacheTTL is the default reuse window for an L3 verdict — the landing +// suggestion of 裁决 14.4.4 (5m): long enough that a repeated phrase inside one +// conversation costs one model call, short enough that a changed habit re-learns. +const DefaultL3CacheTTL = 5 * time.Minute + +// l3Cache memoizes classifier verdicts per turn text for a bounded TTL, so the +// same phrase does not cost a model call on every round. A zero TTL disables +// reuse. The classifier call happens outside the lock: two concurrent turns must +// not serialize on each other's model call. +type l3Cache struct { + classifier IntentClassifier + ttl time.Duration + + mu sync.Mutex + entries map[string]l3Entry +} + +// l3Entry is one cached verdict with the time it was produced. +type l3Entry struct { + judgment Judgment + at time.Time +} + +// newL3Cache builds the cache. A nil classifier yields nil, which the decider +// treats as "L3 not available". +func newL3Cache(classifier IntentClassifier, ttl time.Duration) *l3Cache { + if classifier == nil { + return nil + } + return &l3Cache{classifier: classifier, ttl: ttl, entries: map[string]l3Entry{}} +} + +// classify returns the verdict for text, reusing a fresh cached one when +// possible. The bool reports whether a verdict was produced; an error is returned +// to the caller for reporting instead of being swallowed here. +func (c *l3Cache) classify(ctx context.Context, text string) (Judgment, error) { + key := l3CacheKey(text) + if key == "" { + return Judgment{}, ErrEmptyTurn + } + if j, ok := c.lookup(key, time.Now()); ok { + return j, nil + } + j, err := c.classifier.Classify(ctx, text) + if err != nil { + return Judgment{}, err + } + c.store(key, j, time.Now()) + return j, nil +} + +// lookup returns a fresh cached verdict, if any. +func (c *l3Cache) lookup(key string, now time.Time) (Judgment, bool) { + if c.ttl <= 0 { + return Judgment{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok { + return Judgment{}, false + } + if now.Sub(e.at) > c.ttl { + return Judgment{}, false + } + return e.judgment, true +} + +// store records a verdict, evicting expired entries (and, if the bound is still +// exceeded, the whole map) so the cache cannot grow without limit. +func (c *l3Cache) store(key string, j Judgment, now time.Time) { + if c.ttl <= 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if len(c.entries) >= maxL3CacheEntries { + c.evictExpired(now) + } + c.entries[key] = l3Entry{judgment: j, at: now} +} + +// evictExpired drops entries past their TTL; when none expired, it clears the +// map so a burst of distinct turns cannot pin the memory. +func (c *l3Cache) evictExpired(now time.Time) { + dropped := 0 + for k, e := range c.entries { + if now.Sub(e.at) > c.ttl { + delete(c.entries, k) + dropped++ + } + } + if dropped == 0 { + c.entries = map[string]l3Entry{} + } +} + +// l3CacheKey normalizes a turn's text into the cache key. Empty text (nothing to +// classify) yields "". +func l3CacheKey(text string) string { + return strings.TrimSpace(text) +} diff --git a/internal/runtime/capacity/l3_test.go b/internal/runtime/capacity/l3_test.go new file mode 100644 index 0000000..f3ed2cb --- /dev/null +++ b/internal/runtime/capacity/l3_test.go @@ -0,0 +1,203 @@ +package capacity + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// stubClassifier is a deterministic IntentClassifier: it records every text it +// was asked about so a test can pin whether L3 ran at all (裁决 14.4.4 的 +// CAPACITY_L3_ENABLED 默认关). +type stubClassifier struct { + mu sync.Mutex + seen []string + verdict Judgment + err error + calls int +} + +func (s *stubClassifier) Classify(_ context.Context, text string) (Judgment, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + s.seen = append(s.seen, text) + if s.err != nil { + return Judgment{}, s.err + } + return s.verdict, nil +} + +func (s *stubClassifier) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func l3Config(c IntentClassifier, ttl time.Duration) Config { + cfg := activeConfig() + cfg.L3Enabled = true + cfg.L3Classifier = c + cfg.L3CacheTTL = ttl + return cfg +} + +// TestDecide_L3Disabled_ClassifierNeverRuns pins the default-off red line: with +// CAPACITY_L3_ENABLED false the classifier must never be consulted, even when one +// is configured, so L1/L2/L4 behavior is unchanged. +func TestDecide_L3Disabled_ClassifierNeverRuns(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} + cfg := activeConfig() + cfg.L3Classifier = stub // configured but not enabled + cfg.L3CacheTTL = time.Minute + + // A turn no heuristic can decide: L1 needs a task verb + object, L2 needs a + // leaning window. "那件事后来如何" carries no signal at all. + dec := New(cfg).Decide(context.Background(), in("那件事后来如何")) + if stub.callCount() != 0 { + t.Fatalf("classifier calls = %d, want 0 while L3 is disabled", stub.callCount()) + } + if dec.State != StateQuiet { + t.Fatalf("state = %s, want quiet (conservative default)", dec.State) + } +} + +// TestDecide_L3AwakensUndecidedTurn pins that an enabled L3 layer turns an +// otherwise undecided turn into a wake with the classifier's own confidence, and +// advertises the awake tool set. +func TestDecide_L3AwakensUndecidedTurn(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: "query", Confidence: 0.82}} + dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) + + if stub.callCount() != 1 { + t.Fatalf("classifier calls = %d, want 1", stub.callCount()) + } + if dec.State != StateAwake { + t.Fatalf("state = %s, want awake (L3 verdict)", dec.State) + } + if dec.Confidence != 0.82 { + t.Fatalf("confidence = %v, want the classifier's 0.82", dec.Confidence) + } + if dec.Judgment.IsTask != true { + t.Errorf("judgment = %+v, want the classifier's isTask=true written back for L4", dec.Judgment) + } + if !contains(dec.AllowedTools, "web-search") { + t.Errorf("allowed = %v, want the awake set including web-search", dec.AllowedTools) + } +} + +// TestDecide_L3ComplexKindWakesFull pins the quantified gate's upper branch: a +// "complex" task kind maps to the full state rather than awake. +func TestDecide_L3ComplexKindWakesFull(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: taskKindComplex, Confidence: 0.9}} + dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) + if dec.State != StateFull { + t.Fatalf("state = %s, want full for a complex task kind", dec.State) + } +} + +// TestDecide_L3LowConfidenceIsUndecided pins the conservative gate: a verdict the +// model is unsure about must not wake anything, and must not be written back as a +// task either (the L4 write-back feeds the learning loop, so a hesitant verdict +// recorded as a task would teach the wrong habit). +func TestDecide_L3LowConfidenceIsUndecided(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.3}} + dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) + if stub.callCount() != 1 { + t.Fatalf("classifier calls = %d, want 1", stub.callCount()) + } + if dec.State != StateQuiet { + t.Fatalf("state = %s, want quiet (below-threshold verdict is undecided)", dec.State) + } + if dec.Judgment.IsTask { + t.Fatalf("judgment = %+v, want isTask=false (a below-threshold verdict is not a task)", dec.Judgment) + } +} + +// TestDecide_L3ClassifierErrorKeepsConservativeDefault pins that a model failure +// is reported as "no verdict" rather than crashing or waking tools. +func TestDecide_L3ClassifierErrorKeepsConservativeDefault(t *testing.T) { + stub := &stubClassifier{err: errors.New("upstream down")} + dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) + if dec.State != StateQuiet { + t.Fatalf("state = %s, want quiet after a classifier error", dec.State) + } +} + +// TestDecide_L3CacheReusesVerdict pins the L3 output cache (裁决 14.4.4): the same +// turn text costs one model call inside the TTL, and a different turn still costs +// its own call. +func TestDecide_L3CacheReusesVerdict(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} + d := New(l3Config(stub, time.Minute)) + ctx := context.Background() + + for i := 0; i < 3; i++ { + if dec := d.Decide(ctx, in("那件事后来如何")); dec.State != StateAwake { + t.Fatalf("call %d state = %s, want awake", i, dec.State) + } + } + if got := stub.callCount(); got != 1 { + t.Fatalf("classifier calls = %d, want 1 (cached verdict reused)", got) + } + d.Decide(ctx, in("那件事后来如何?")) + if got := stub.callCount(); got != 2 { + t.Fatalf("classifier calls = %d, want 2 (different text is not cached)", got) + } +} + +// TestL3Cache_ExpiredEntryIsReclassified pins the TTL bound itself. +func TestL3Cache_ExpiredEntryIsReclassified(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} + c := newL3Cache(stub, time.Minute) + ctx := context.Background() + if _, err := c.classify(ctx, "那件事后来如何"); err != nil { + t.Fatalf("classify: %v", err) + } + // Backdate the stored entry past the TTL. + c.mu.Lock() + for k, e := range c.entries { + e.at = time.Now().Add(-2 * time.Minute) + c.entries[k] = e + } + c.mu.Unlock() + if _, err := c.classify(ctx, "那件事后来如何"); err != nil { + t.Fatalf("classify: %v", err) + } + if got := stub.callCount(); got != 2 { + t.Fatalf("classifier calls = %d, want 2 (expired entry reclassified)", got) + } +} + +// TestL3Cache_EmptyTextIsNotClassified pins that an empty turn is a sentinel +// error rather than a model call. +func TestL3Cache_EmptyTextIsNotClassified(t *testing.T) { + stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} + c := newL3Cache(stub, time.Minute) + if _, err := c.classify(context.Background(), " "); !errors.Is(err, ErrEmptyTurn) { + t.Fatalf("err = %v, want ErrEmptyTurn", err) + } + if stub.callCount() != 0 { + t.Fatalf("classifier calls = %d, want 0", stub.callCount()) + } +} + +// TestParseL3Judgment pins the wire parsing: a fenced JSON reply is accepted, a +// prose reply is an error, and a hallucinated confidence is clamped. +func TestParseL3Judgment(t *testing.T) { + ok, err := parseL3Judgment("```json\n{\"isTask\":true,\"taskKind\":\"query\",\"confidence\":0.7}\n```") + if err != nil { + t.Fatalf("parseL3Judgment: %v", err) + } + if !ok.IsTask || ok.TaskKind != "query" || ok.Confidence != 0.7 { + t.Fatalf("parsed = %+v, want isTask=true taskKind=query confidence=0.7", ok) + } + if _, err := parseL3Judgment("我觉得这是一个任务"); err == nil { + t.Fatal("prose reply parsed without error, want failure") + } + if got := clampUnit(7.5); got != 1 { + t.Errorf("clampUnit(7.5) = %v, want 1", got) + } +} diff --git a/internal/runtime/capacity/signals.go b/internal/runtime/capacity/signals.go index a731223..3a286fc 100644 --- a/internal/runtime/capacity/signals.go +++ b/internal/runtime/capacity/signals.go @@ -1,6 +1,11 @@ +// signals.go holds the intent layer vocabulary and the L1/L2/L3 layers; the +// package doc lives in capacity.go. package capacity -import "strings" +import ( + "context" + "strings" +) // defaultQuietTools is the companion-safe face (design §1): terminal + file edit // + memory/emotion companion. It is a function building the slice fresh so no @@ -16,12 +21,13 @@ func defaultAwakeTools() []string { } // intent classifies the round. It runs L1 on the current turn, falls to L2 on -// the recent context trend, and folds in the L4 learned-habit verdict (design §2.2) -// at each layer: L4 may correct a decided result (boost / quiet→awake) or fill in -// when L1/L2 are undecided. With no matching habit L4 is silent, so a turn with no -// recall behaves exactly as before; otherwise the result stays conservative (低置信 -// still rests via applyAwakening). -func (d *Decider) intent(in Input) (State, float64, string) { +// the recent context trend, then to the model-assisted L3 layer when it is +// enabled, and folds in the L4 learned-habit verdict (design §2.2) at each layer: +// L4 may correct a decided result (boost / quiet→awake) or fill in when L1/L2/L3 +// are undecided. With no matching habit L4 is silent, so a turn with no recall +// behaves exactly as before; otherwise the result stays conservative (低置信 still +// rests via applyAwakening). +func (d *Decider) intent(ctx context.Context, in Input) (State, float64, string) { if len(in.Messages) == 0 { return "", 0, "" } @@ -33,12 +39,44 @@ func (d *Decider) intent(in Input) (State, float64, string) { if s, c, t, ok := l2(in.Messages); ok { return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok) } + if s, c, t, ok := d.l3(ctx, current); ok { + return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok) + } if l4ok { return l4s, l4c, l4t } return "", 0.3, "" // 拿不准 → conservative fallback } +// l3 runs the model-assisted intent layer (design/14 §2.2 L3; 裁决 14.4.4): the +// configured classifier judges the current turn and its verdict is trusted only +// at or above the wake threshold — a low-confidence verdict is treated as +// undecided, so a hesitant model can never wake tools on its own. A classifier +// error is reported and treated the same way. With the layer off this returns +// ok=false immediately, leaving L1/L2/L4 behavior byte-identical. +func (d *Decider) l3(ctx context.Context, current string) (State, float64, string, bool) { + if d.l3cache == nil { + return "", 0, "", false + } + j, err := d.l3cache.classify(ctx, current) + if err != nil { + d.log.Warn("capacity L3 classifier unavailable, keeping the conservative default", "error", err) + return "", 0, "", false + } + if j.Confidence < d.cfg.MinConfidence { + d.log.Debug("capacity L3 verdict below threshold, treated as undecided", + "confidence", j.Confidence, "threshold", d.cfg.MinConfidence, "is_task", j.IsTask) + return "", 0, "", false + } + if !j.IsTask { + return StateQuiet, j.Confidence, "", true + } + if j.TaskKind == taskKindComplex { + return StateFull, j.Confidence, inferTool(current), true + } + return StateAwake, j.Confidence, inferTool(current), true +} + // l1 is the fast heuristic on the current turn (design §2.2 L1): a strong // action signal wakes (awake, or full for complex work); a clear companion // signal (weak + no task object) stays quiet; otherwise it is undecided and the diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 44565e8..81b150e 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -13,6 +13,10 @@ import ( // RunStep 一致(前导指令消息、工具 schema、模型路由)。agent // 与推理旋钮从 ctx 解析,因此切换了角色的会话 // 在其下一步就拿到新角色的提示词与预算(design/22 §4.2)。 +// +// 模型路由按该 turn 的角色(persona)解析(裁决 03.4.1)。agent.buildRequest +// (非流式路径,internal/runtime/agent/request.go)仍只按消息 role 路由—— +// 那条路径的 persona 接线不在本批次文件面内,已在回报中登记。 func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request { req := model.Request{ Model: ag.Model(), @@ -25,6 +29,15 @@ func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *a ReasoningEffort: knobs.ReasoningEffort, } if ag.Router() != nil { + // 按 turn 的角色(persona)路由(裁决 03.4.1:per-role 的「角色」= 人格, + // 不是 LLM 消息 role)。路由器的 persona 感知是可选的扩展接口 + // (model.PersonaRouter),因此只按消息 role 路由的注入方行为不变。 + if pr, ok := ag.Router().(model.PersonaRouter); ok { + if m, _ := pr.RouteRequestForPersona(l.personaFor(ctx), req); m != "" { + req.Model = m + } + return req + } if m, _ := ag.Router().RouteRequest(req); m != "" { req.Model = m } diff --git a/internal/runtime/model/factory.go b/internal/runtime/model/factory.go index e80d1cf..02ca829 100644 --- a/internal/runtime/model/factory.go +++ b/internal/runtime/model/factory.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) @@ -48,15 +49,67 @@ func NewChatChain(cfg config.LLMConfig) (*Chain, error) { // builds, so a provider-level reasoning decision (a thinking budget raised to fit // max_tokens, a reasoning_effort value downgraded or ignored) is reported with // its module source. A nil logger leaves the adapters' discard default in place. +// +// It is also the only builder that appends the optional local third rung +// (裁决 03.4.3, primary → fallback → local), because that rung may speak a +// different protocol than the active group and therefore needs the raw logger to +// build its own adapter. It is appended only when the local rung is enabled AND +// fully configured; otherwise the chain keeps its two-level shape and the reason +// is reported. func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) { + var chain *Chain + var err error switch cfg.Provider { case ProviderOpenAI, "": - return newOpenAIChain(cfg, WithOpenAILogger(log)) + chain, err = newOpenAIChain(cfg, WithOpenAILogger(log)) case ProviderAnthropic: - return newAnthropicChain(cfg, WithAnthropicLogger(log)) + chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log)) default: return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider) } + if err != nil { + return nil, err + } + if step, ok := localChainStep(cfg, log); ok { + chain.steps = append(chain.steps, step) + } + return chain, nil +} + +// localChainStep builds the local third degradation rung as a chain step. It +// returns ok=false (and, when the rung was asked for but is incomplete, a WARN +// naming the missing field) when the rung is off, unconfigured or its provider is +// unknown — a half-configured step would only ever fail, which is worse than not +// having it. +func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { + local := cfg.Local + // Configured() already requires Enabled, so the rung is off by default and a + // disabled rung can never become a chain step. The warning is emitted only + // when the rung was actually asked for but is incomplete. + if !local.Configured() { + if local.Enabled { + warn(log, "local model rung enabled but not configured; skipping it", + "base_url_set", strings.TrimSpace(local.BaseURL) != "", + "model_set", strings.TrimSpace(local.Model) != "") + } + return Step{}, false + } + localCfg := config.LLMConfig{ + Provider: local.Provider, + BaseURL: local.BaseURL, + APIKey: local.APIKey, + Model: local.Model, + } + switch local.Provider { + case ProviderAnthropic: + return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true + case ProviderOpenAI, "": + return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true + default: + warn(log, "local model rung skipped: unknown provider", + "provider", local.Provider, "field", "LLM_LOCAL_PROVIDER") + return Step{}, false + } } // newOpenAIChain builds a fallback chain from the OpenAI-compatible config block. @@ -94,3 +147,14 @@ func DefaultModelForProvider(cfg config.LLMConfig) string { } return cfg.Model } + +// warn reports through the injected logger, falling back to a discard logger that +// still carries the module source so an unsourced record can never be emitted +// (project rule §10). It exists because a chain may be built without a logger +// (NewChatChain) while a skipped rung must still be reported. +func warn(log *slog.Logger, msg string, args ...any) { + if log == nil { + log = discardLogger("model") + } + log.Warn(msg, args...) +} diff --git a/internal/runtime/model/fallback.go b/internal/runtime/model/fallback.go index c7e45e3..2a867f2 100644 --- a/internal/runtime/model/fallback.go +++ b/internal/runtime/model/fallback.go @@ -44,6 +44,19 @@ func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain { return c } +// stepModel picks the model name for step idx of the chain. The primary step +// prefers the request's routed model — the per-persona / per-request decision the +// Router produced — over the group's configured model, so a routing decision is +// never silently discarded. Every fallback step pins its own configured model, so +// a degradation never re-sends the primary model name to a different endpoint +// (裁决 03.4.1: per-persona 路由必须真的落到请求上). +func stepModel(req Request, idx int, configured string) string { + if idx == 0 && req.Model != "" { + return req.Model + } + return configured +} + // applyBudget resolves the effective MaxTokens for step idx, honoring the // precedence: an explicit Request.MaxTokens > the group's MaxTokens > the // last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort @@ -85,8 +98,8 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response, var lastErr error for i, s := range c.steps { stepReq := c.applyBudget(req, i) - if s.Model != "" { - stepReq.Model = s.Model + if m := stepModel(req, i, s.Model); m != "" { + stepReq.Model = m } resp, err := s.Adapter.Chat(ctx, stepReq, opts) if err == nil { @@ -108,8 +121,8 @@ func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(Stream var lastErr error for i, s := range c.steps { stepReq := c.applyBudget(req, i) - if s.Model != "" { - stepReq.Model = s.Model + if m := stepModel(req, i, s.Model); m != "" { + stepReq.Model = m } delivered := false wrapped := func(ev StreamEvent) { diff --git a/internal/runtime/model/local_rung_test.go b/internal/runtime/model/local_rung_test.go new file mode 100644 index 0000000..7dd17d5 --- /dev/null +++ b/internal/runtime/model/local_rung_test.go @@ -0,0 +1,187 @@ +package model + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// recordingServer is an OpenAI-compatible /chat/completions endpoint that records +// the model name of every request and either fails or succeeds. It backs the +// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches +// the local endpoint with the local model name, and never when the rung is off. +type recordingServer struct { + mu sync.Mutex + models []string +} + +func (s *recordingServer) modelsSeen() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.models...) +} + +func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingServer) { + t.Helper() + rec := &recordingServer{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Model string `json:"model"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + rec.mu.Lock() + rec.models = append(rec.models, body.Model) + rec.mu.Unlock() + if fail { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"down"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, okCompletion) + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level +// (primary → fallback → local) only joins the chain when it is enabled AND fully +// configured. +func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) { + t.Parallel() + cases := []struct { + name string + local config.LocalLLMConfig + want int + }{ + {"off", config.LocalLLMConfig{BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2}, + {"enabled but no model", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1"}, 2}, + {"enabled but no base url", config.LocalLLMConfig{Enabled: true, Model: "local"}, 2}, + {"enabled and configured", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 3}, + {"enabled with unknown provider", config.LocalLLMConfig{Enabled: true, Provider: "gemini", BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + chain, err := NewChatChainWithLog(config.LLMConfig{ + Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "main", Fallback: "fb", + Local: c.local, + }, nil) + if err != nil { + t.Fatalf("NewChatChainWithLog: %v", err) + } + if len(chain.steps) != c.want { + t.Fatalf("steps = %d, want %d", len(chain.steps), c.want) + } + if c.want == 3 && chain.steps[2].Model != "local" { + t.Errorf("steps[2].Model = %q, want local", chain.steps[2].Model) + } + }) + } +} + +// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes +// over the request after the primary and fallback steps fail, with the local model +// name on the wire and the local endpoint's own base URL/API key (not the group's). +func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) { + t.Parallel() + mainSrv, _ := newRecordingServer(t, true) + localSrv, localRec := newRecordingServer(t, false) + + chain, err := NewChatChainWithLog(config.LLMConfig{ + Provider: ProviderOpenAI, BaseURL: mainSrv.URL, APIKey: "sk-main", Model: "main", Fallback: "fb", + Local: config.LocalLLMConfig{Enabled: true, BaseURL: localSrv.URL, APIKey: "sk-local", Model: "local-model"}, + }, nil) + if err != nil { + t.Fatalf("NewChatChainWithLog: %v", err) + } + resp, err := chain.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Purpose: "chat"}, Options{}) + if err != nil { + t.Fatalf("Chain.Chat: %v", err) + } + if resp.Content != "Hello!" { + t.Errorf("resp.Content = %q, want Hello!", resp.Content) + } + seen := localRec.modelsSeen() + if len(seen) != 1 || seen[0] != "local-model" { + t.Fatalf("local endpoint models = %v, want [local-model]", seen) + } +} + +// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the +// rung disabled the local endpoint must never be touched, even when every other +// step fails. +func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) { + t.Parallel() + mainSrv, _ := newRecordingServer(t, true) + localSrv, localRec := newRecordingServer(t, false) + + chain, err := NewChatChainWithLog(config.LLMConfig{ + Provider: ProviderOpenAI, BaseURL: mainSrv.URL, Model: "main", Fallback: "fb", + Local: config.LocalLLMConfig{BaseURL: localSrv.URL, Model: "local-model"}, + }, nil) + if err != nil { + t.Fatalf("NewChatChainWithLog: %v", err) + } + if _, err := chain.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + if seen := localRec.modelsSeen(); len(seen) != 0 { + t.Fatalf("local endpoint called %v, want never called while the rung is off", seen) + } +} + +// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard +// the router's decision: the primary step sends the request's routed model, and +// the fallback step still pins its own configured model (裁决 03.4.1). +func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) { + t.Parallel() + // One endpoint serves both steps (the factory builds every step against the + // group's base URL): it fails for anything but the fallback model, so the + // recorded model names show which model each step actually sent. + rec := &recordingServer{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Model string `json:"model"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + rec.mu.Lock() + rec.models = append(rec.models, body.Model) + rec.mu.Unlock() + if body.Model != "group-fb" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"down"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, okCompletion) + })) + t.Cleanup(srv.Close) + + chain, err := NewChatChainWithLog(config.LLMConfig{ + Provider: ProviderOpenAI, BaseURL: srv.URL, Model: "group-main", Fallback: "group-fb", + }, nil) + if err != nil { + t.Fatalf("NewChatChainWithLog: %v", err) + } + if _, err := chain.Chat(context.Background(), Request{ + Model: "persona-main", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chain.Chat: %v", err) + } + seen := rec.modelsSeen() + if len(seen) != 2 || seen[0] != "persona-main" || seen[1] != "group-fb" { + t.Fatalf("models seen = %v, want [persona-main group-fb] (routed model first, step model on fallback)", seen) + } +} diff --git a/internal/runtime/model/persona_router_test.go b/internal/runtime/model/persona_router_test.go new file mode 100644 index 0000000..c4b3a51 --- /dev/null +++ b/internal/runtime/model/persona_router_test.go @@ -0,0 +1,119 @@ +package model + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role +// routing is the persona a turn answers as, and a persona without a binding keeps +// the message-role behavior. +func TestRouter_RouteRequestForPersona(t *testing.T) { + t.Parallel() + r := NewRouter("default", + WithRoleModel(RoleUser, "user-model"), + WithPersonaModel("cyrene", "persona-model"), + WithPersonaProvider("cyrene", ProviderAnthropic), + WithDefaultProvider(ProviderOpenAI), + ) + req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}} + + if got, _ := r.RouteRequestForPersona("cyrene", req); got != "persona-model" { + t.Errorf("RouteRequestForPersona(cyrene) = %q, want persona-model", got) + } + if got, _ := r.RouteRequestForPersona("other", req); got != "user-model" { + t.Errorf("RouteRequestForPersona(other) = %q, want user-model (role fallback)", got) + } + if got, _ := r.RouteRequestForPersona("", req); got != "user-model" { + t.Errorf("RouteRequestForPersona(\"\") = %q, want user-model", got) + } + if got := r.RouteForPersona("cyrene"); got != "persona-model" { + t.Errorf("RouteForPersona(cyrene) = %q, want persona-model", got) + } + if got := r.RouteProviderForPersona("cyrene"); got != ProviderAnthropic { + t.Errorf("RouteProviderForPersona(cyrene) = %q, want anthropic", got) + } + if got := r.RouteProviderForPersona("other"); got != ProviderOpenAI { + t.Errorf("RouteProviderForPersona(other) = %q, want the default provider openai", got) + } +} + +// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a +// blank persona/model cannot create a binding, and a persona-routed image request +// is not silently re-routed to a vision model (that decision belongs to +// vision.Router, 裁决 14.4.1/14.4.5). +func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) { + t.Parallel() + r := NewRouter("default", + WithPersonaModel(" ", "x"), + WithPersonaModel("cyrene", ""), + WithVisionModel("vision-model"), + ) + if got := r.RouteForPersona("cyrene"); got != "default" { + t.Errorf("RouteForPersona(cyrene) = %q, want default (no binding stored)", got) + } + img := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}} + got, needsVision := r.RouteRequestForPersona("cyrene", img) + if !needsVision { + t.Error("needsVision = false, want true for an image-bearing request") + } + if got == "vision-model" { + t.Errorf("RouteRequestForPersona = %q, want the persona/role model, not the vision model", got) + } +} + +// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona +// binding written on the models.json group reaches the router through the hot +// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。 +func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) { + path := writeRTFile(t, []runtimeconfig.Group{{ + ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model", + PersonaModels: map[string]string{"cyrene": "persona-model"}, + PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}, + }}) + m := NewRuntimeLLM(path, rtEnv(), nil) + + req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}} + got, _ := m.RouteRequestForPersona("cyrene", req) + if got != "persona-model" { + t.Fatalf("RouteRequestForPersona(cyrene) = %q, want persona-model", got) + } + if got, _ := m.RouteRequestForPersona("other", req); got != "group-model" { + t.Fatalf("RouteRequestForPersona(other) = %q, want group-model", got) + } + + if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{{ + ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model", + }}); err != nil { + t.Fatalf("SaveModels: %v", err) + } + if err := m.Reload(); err != nil { + t.Fatalf("Reload: %v", err) + } + if got, _ := m.RouteRequestForPersona("cyrene", req); got != "group-model" { + t.Fatalf("after reload RouteRequestForPersona(cyrene) = %q, want group-model (binding removed)", got) + } +} + +// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer +// carries the env vision model: a configured LLM_VISION_MODEL must not switch an +// image-bearing request to another model, because image routing is owned by +// vision.Router (裁决 14.4.1/14.4.5). +func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) { + path := writeRTFile(t, []runtimeconfig.Group{{ + ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model", + }}) + env := rtEnv() + env.VisionModel = "vision-model" + m := NewRuntimeLLM(path, env, nil) + + req := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}} + got, needsVision := m.RouteRequest(req) + if !needsVision { + t.Error("needsVision = false, want true for an image-bearing request") + } + if got != "group-model" { + t.Errorf("RouteRequest = %q, want group-model (no vision pre-emption)", got) + } +} diff --git a/internal/runtime/model/router.go b/internal/runtime/model/router.go index 007dd27..5c2fd21 100644 --- a/internal/runtime/model/router.go +++ b/internal/runtime/model/router.go @@ -1,17 +1,31 @@ package model +import "strings" + // Router maps roles to model names. An unknown role resolves to the configured -// default model. When a request carries image content and a vision model is -// configured, the request routes to the vision model. A Router also carries a -// provider per role (defaulting to the global provider) so a multi-vendor -// deployment can pair a role's model with the correct protocol; the provider -// selection is surfaced via RouteProvider. +// default model. A Router also carries a provider per role (defaulting to the +// global provider) so a multi-vendor deployment can pair a role's model with the +// correct protocol; the provider selection is surfaced via RouteProvider. +// +// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn +// answers as, not the LLM message role: a persona binding wins over the +// message-role binding, and a persona without a binding keeps the previous +// behavior. The binding carrier is the active models.json group +// (runtimeconfig.Group.PersonaModels / PersonaProviders). +// +// Image routing is deliberately NOT this router's job any more (裁决 +// 14.4.1/14.4.5): the single authority for layered image routing is +// runtime/multimodal/vision.Router, which the loop calls with the main model's +// real image-input capability. A "has image → switch to the vision model" rule +// here used to bypass that decision. type Router struct { defaultModel string byRole map[Role]string visionModel string defaultProvider string providerByRole map[Role]string + personaModel map[string]string + personaProvider map[string]string } // RouterOption configures a Router. @@ -22,11 +36,36 @@ func WithRoleModel(role Role, model string) RouterOption { return func(r *Router) { r.byRole[role] = model } } -// WithVisionModel configures the dedicated vision model. +// WithVisionModel configures the dedicated vision model. It is kept for +// backwards compatibility (tests and callers that route a pure image request by +// model name) but production no longer sets it: image routing belongs to +// vision.Router (裁决 14.4.1/14.4.5). func WithVisionModel(model string) RouterOption { return func(r *Router) { r.visionModel = model } } +// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or +// model is ignored so a partially configured group cannot create a binding that +// routes a persona to an empty model. +func WithPersonaModel(persona, model string) RouterOption { + return func(r *Router) { + if p := trimKey(persona); p != "" && model != "" { + r.personaModel[p] = model + } + } +} + +// WithPersonaProvider binds a vendor protocol to a persona, so one persona can +// answer through a different vendor than the group default (裁决 03.4.1). Pair it +// with WithPersonaModel. +func WithPersonaProvider(persona, provider string) RouterOption { + return func(r *Router) { + if p := trimKey(persona); p != "" && provider != "" { + r.personaProvider[p] = provider + } + } +} + // WithDefaultProvider sets the provider returned by RouteProvider for roles with // no explicit binding. It defaults to openai. func WithDefaultProvider(provider string) RouterOption { @@ -48,6 +87,8 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router { byRole: map[Role]string{}, defaultProvider: ProviderOpenAI, providerByRole: map[Role]string{}, + personaModel: map[string]string{}, + personaProvider: map[string]string{}, } for _, o := range opts { o(r) @@ -55,6 +96,10 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router { return r } +// trimKey normalizes a persona id or provider name before it is stored, so +// " cyrene" and "cyrene" cannot become two different bindings. +func trimKey(k string) string { return strings.TrimSpace(k) } + // Route returns the model for a role, falling back to the default model. func (r *Router) Route(role Role) string { if m, ok := r.byRole[role]; ok && m != "" { @@ -76,10 +121,15 @@ func (r *Router) RouteProvider(role Role) string { } // RouteRequest picks the model for a request and reports whether the request -// needs a vision model. When the request carries image content and a vision +// carries image content. When the request carries image content and a vision // model is configured, the vision model is returned with needsVision set to // true. Otherwise the role of the last message drives routing (user by // default). +// +// Deprecated in the production path: the loop routes by persona via +// RouteRequestForPersona, and image routing belongs to vision.Router (裁决 +// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the +// agent package and the existing tests are built on. func (r *Router) RouteRequest(req Request) (model string, needsVision bool) { role := RoleUser if n := len(req.Messages); n > 0 { @@ -92,6 +142,51 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) { return r.Route(role), needsVision } +// PersonaRouter is the optional persona-aware routing extension of ModelRouter +// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over +// RouteRequest, so a per-persona model/provider binding actually reaches the +// request. It is a separate interface so a router that only knows message roles +// keeps working unchanged. +type PersonaRouter interface { + RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) +} + +// RouteForPersona returns the model bound to persona, falling back to the +// router's default model. Unlike Route (message role) this is the persona-level +// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona. +func (r *Router) RouteForPersona(persona string) string { + if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" { + return m + } + return r.defaultModel +} + +// RouteProviderForPersona returns the provider bound to persona, falling back to +// the router's default provider (itself the active group's protocol). +func (r *Router) RouteProviderForPersona(persona string) string { + if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" { + return p + } + return r.defaultProvider +} + +// RouteRequestForPersona routes a request as the given persona: a persona +// binding wins over the message-role decision, and a persona without a binding +// falls back to RouteRequest so nothing changes for the unconfigured case. It +// reports whether the request carries an image, but never switches the model to +// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5). +func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) { + needsVision = HasImage(req.Messages) + if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" { + return m, needsVision + } + role := RoleUser + if n := len(req.Messages); n > 0 { + role = req.Messages[n-1].Role + } + return r.Route(role), needsVision +} + // HasImage reports whether any message carries an image content part. func HasImage(msgs []Message) bool { for _, m := range msgs { diff --git a/internal/runtime/model/runtime.go b/internal/runtime/model/runtime.go index 34399c2..3335830 100644 --- a/internal/runtime/model/runtime.go +++ b/internal/runtime/model/runtime.go @@ -125,6 +125,17 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) { return r.RouteRequest(req) } +// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes +// a turn as the persona it answers as, and a per-persona binding in the active +// models.json group reaches the request through here. +func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) { + r := m.load().router + if r == nil { + return "", false + } + return r.RouteRequestForPersona(personaID, req) +} + // loadInitial builds the first snapshot; a build failure leaves a nil chain so // calls report a clear no-model error instead of panicking. func (m *RuntimeLLM) loadInitial() { @@ -160,11 +171,20 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot { // buildSnapshot turns the active group (index 0) into a chain+router. A group // without a base URL/model yields a nil chain (no model configured), surfaced as -// ErrNoModel by the adapter methods. The Router keeps the env vision model so a -// dedicated vision model survives a reload that only changes the vendor group. +// ErrNoModel by the adapter methods. +// +// The env-local rung is copied onto the group's flat config because a models.json +// group does not carry it: without that copy the third degradation level +// (裁决 03.4.3) would silently disappear as soon as models.json exists. +// +// The router is seeded with the active group's per-persona bindings +// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing +// is owned by vision.Router, and a "has image → switch model" rule here bypassed +// that decision (裁决 14.4.1/14.4.5). func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) { active := groups[0] cfg := active.ToLLMConfig() + cfg.Local = env.Local chain, err := NewChatChainWithLog(cfg, log) if err != nil && !errors.Is(err, ErrNoModel) { return nil, err @@ -175,9 +195,20 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog // last step of a chain that really has a fallback (design/04 §3.1). chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens) } - router := NewRouter(DefaultModelForProvider(cfg), - WithVisionModel(env.VisionModel), - WithDefaultProvider(cfg.Provider), - ) + router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...) return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil } + +// personaRouterOptions turns the active group's per-persona model/provider +// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot +// stays inside the 80-line function budget. +func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption { + opts := []RouterOption{WithDefaultProvider(cfg.Provider)} + for persona, model := range active.PersonaModels { + opts = append(opts, WithPersonaModel(persona, model)) + } + for persona, provider := range active.PersonaProviders { + opts = append(opts, WithPersonaProvider(persona, provider)) + } + return opts +} diff --git a/internal/runtime/multimodal/vision/adapter.go b/internal/runtime/multimodal/vision/adapter.go index 58e5ded..09d6bd7 100644 --- a/internal/runtime/multimodal/vision/adapter.go +++ b/internal/runtime/multimodal/vision/adapter.go @@ -35,11 +35,16 @@ type OCRAdapter interface { // endpoint(图像作为内容部件内嵌)同时实现 VisionAdapter 与 OCRAdapter。 // 它是开箱可用的默认实现;真实的 // 云端 vision/OCR 提供方暴露相同协议。 +// +// 两个方向可以跑在同一个 endpoint 的两个模型上(裁决 14.4.1 的「独立视觉 +// 模型 + OCR 模型」):Describe 用 model(LLM_VISION_MODEL),Extract 用 +// ocrModel(LLM_OCR_MODEL),未配 OCR 模型时回落到同一个模型。 type OpenAICompatibleAdapter struct { - cfg config.LLMConfig - model string - client *http.Client - log *slog.Logger + cfg config.LLMConfig + model string + ocrModel string + client *http.Client + log *slog.Logger } // OpenAIOption 用于定制 OpenAICompatibleAdapter。 @@ -72,6 +77,16 @@ func WithLogger(l *slog.Logger) OpenAIOption { } } +// WithOCRModel 覆盖 OCR 方向使用的模型(默认取 cfg.OCRModel,再回落 +// 到 model)。 +func WithOCRModel(m string) OpenAIOption { + return func(a *OpenAICompatibleAdapter) { + if m != "" { + a.ocrModel = m + } + } +} + // NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用 // cfg.Model。 func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter { @@ -84,6 +99,13 @@ func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIComp for _, o := range opts { o(a) } + if a.ocrModel == "" { + // LLM_OCR_MODEL 未配置时 OCR 与视觉共用同一个模型(向后兼容)。 + a.ocrModel = cfg.OCRModel + } + if a.ocrModel == "" { + a.ocrModel = a.model + } return a } @@ -92,20 +114,22 @@ func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, pr if prompt == "" { prompt = "Describe this image in detail." } - return a.ask(ctx, img, prompt) + return a.ask(ctx, a.model, img, prompt) } -// Extract 经同一 endpoint 返回图像中发现的文字。 +// Extract 经同一 endpoint 返回图像中发现的文字;当配置了独立 OCR 模型 +// (LLM_OCR_MODEL / WithOCRModel)时用它,否则与视觉模型相同。 func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) { - return a.ask(ctx, img, "Extract all text visible in this image. Return only the text.") + return a.ask(ctx, a.ocrModel, img, "Extract all text visible in this image. Return only the text.") } -// ask 解析图像、构造 chat 请求并返回助手文本。 -func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt string) (string, error) { +// ask 解析图像、构造 chat 请求并返回助手文本。model 是本次请求使用的模型, +// 让两个方向能在同一 endpoint 上跑不同模型。 +func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img ImageRef, prompt string) (string, error) { if strings.TrimSpace(a.cfg.BaseURL) == "" { return "", ErrNotConfigured } - if strings.TrimSpace(a.model) == "" { + if strings.TrimSpace(model) == "" { return "", ErrNotConfigured } if err := ctx.Err(); err != nil { @@ -115,7 +139,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt if err != nil { return "", err } - payload, err := json.Marshal(a.request(imageURL, prompt)) + payload, err := json.Marshal(a.request(model, imageURL, prompt)) if err != nil { return "", fmt.Errorf("vision: %w: marshal request: %v", ErrUpstream, err) } @@ -187,9 +211,9 @@ type openAIResponseMessage struct { } // request 构造图像 + prompt 的线上请求体。 -func (a *OpenAICompatibleAdapter) request(imageURL, prompt string) openAIVisionRequest { +func (a *OpenAICompatibleAdapter) request(model, imageURL, prompt string) openAIVisionRequest { return openAIVisionRequest{ - Model: a.model, + Model: model, Messages: []openAIMessage{ {Role: "user", Content: []openAIPart{ {Type: "text", Text: prompt}, diff --git a/internal/runtime/multimodal/vision/ocr_model_test.go b/internal/runtime/multimodal/vision/ocr_model_test.go new file mode 100644 index 0000000..46b51b6 --- /dev/null +++ b/internal/runtime/multimodal/vision/ocr_model_test.go @@ -0,0 +1,89 @@ +package vision + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// modelRecorder is a /chat/completions endpoint that records the requested model, +// so a test can pin which model each direction (Describe / Extract) asked for. +type modelRecorder struct { + mu sync.Mutex + models []string +} + +func (r *modelRecorder) seen() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.models...) +} + +func newModelRecorderServer(t *testing.T) (*httptest.Server, *modelRecorder) { + t.Helper() + rec := &modelRecorder{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Model string `json:"model"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + rec.mu.Lock() + rec.models = append(rec.models, body.Model) + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`) + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// TestOpenAICompatibleAdapter_OCRUsesOwnModel pins the independent OCR model +// (裁决 14.4.1 的「独立视觉模型 + OCR 模型」): when LLM_OCR_MODEL is set the OCR +// direction sends it, while Describe keeps the vision model. +func TestOpenAICompatibleAdapter_OCRUsesOwnModel(t *testing.T) { + t.Parallel() + srv, rec := newModelRecorderServer(t) + ad := NewOpenAICompatible(config.LLMConfig{ + BaseURL: srv.URL, Model: "vision-main", OCRModel: "ocr-special", + }) + + if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, ""); err != nil { + t.Fatalf("Describe: %v", err) + } + if _, err := ad.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil { + t.Fatalf("Extract: %v", err) + } + seen := rec.seen() + if len(seen) != 2 || seen[0] != "vision-main" || seen[1] != "ocr-special" { + t.Fatalf("models seen = %v, want [vision-main ocr-special]", seen) + } +} + +// TestOpenAICompatibleAdapter_OCRWithoutOwnModelFallsBack pins the compatibility +// edge: without LLM_OCR_MODEL both directions use the vision model. +func TestOpenAICompatibleAdapter_OCRWithoutOwnModelFallsBack(t *testing.T) { + t.Parallel() + srv, rec := newModelRecorderServer(t) + ad := NewOpenAICompatible(config.LLMConfig{BaseURL: srv.URL, Model: "vision-main"}) + + if _, err := ad.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil { + t.Fatalf("Extract: %v", err) + } + if seen := rec.seen(); len(seen) != 1 || seen[0] != "vision-main" { + t.Fatalf("models seen = %v, want [vision-main]", seen) + } + // WithOCRModel overrides cfg.OCRModel, so an explicit option still wins. + ad2 := NewOpenAICompatible(config.LLMConfig{BaseURL: srv.URL, Model: "vision-main", OCRModel: "cfg-ocr"}, WithOCRModel("opt-ocr")) + if _, err := ad2.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil { + t.Fatalf("Extract: %v", err) + } + if seen := rec.seen(); len(seen) != 2 || seen[1] != "opt-ocr" { + t.Fatalf("models seen = %v, want the explicit option opt-ocr", seen) + } +} diff --git a/internal/runtime/multimodal/vision/router.go b/internal/runtime/multimodal/vision/router.go index c4dcff2..215b42c 100644 --- a/internal/runtime/multimodal/vision/router.go +++ b/internal/runtime/multimodal/vision/router.go @@ -1,6 +1,12 @@ package vision // Assessment 是模型对自己读图结果的置信度。 +// +// Deprecated placeholder(裁决 14.4.1③/14.4.5②):第二层不再是「结构化自评」, +// 而是主模型自己按需调用视觉/OCR 工具(design/06 §0 第 2 层)。因此 +// SelfAssessment 目前没有任何生产者:主模型要复核读图效果时,直接调用 +// vision/ocr 工具即可,本包不再要求它吐出一个自评字段。保留该类型只为兼容 +// 既有调用面;新代码不要依赖它。 type Assessment string const ( @@ -49,6 +55,11 @@ type Decision struct { } // RouteRequest 携带调用方在模型信息之外给出的路由提示。 +// +// Deprecated(裁决 14.4.5②):生产没有任何调用方填充这两个提示 —— 图像是转成 +// 文字还是留给主模型,由主模型的能力声明(models.json multimodal)与它自己 +// 按需调用视觉/OCR 工具决定,不再靠调用方猜「这张图主要是文字」。字段保留 +// 只为兼容既有测试与调用面。 type RouteRequest struct { // TextPrimary 表示用户指出图像以文字为主,因此 // 优先使用 OCR 工具而非通用视觉模型。 diff --git a/plugins/tools/io/crop.go b/plugins/tools/io/crop.go new file mode 100644 index 0000000..d97581e --- /dev/null +++ b/plugins/tools/io/crop.go @@ -0,0 +1,278 @@ +package io + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "image" + "image/draw" + _ "image/gif" // register the GIF decoder for image.Decode + _ "image/jpeg" // register the JPEG decoder for image.Decode + "image/png" + "log/slog" + "net/http" + "os" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Compile-time assertion that cropTool implements sdk.Tool. +var _ sdk.Tool = (*cropTool)(nil) + +// Crop output limits. An upstream vision/OCR service downsamples a large image +// before it sees it (裁决 14.4.5③), so the way to read a region in full detail is +// to crop it and re-read the crop; these caps keep a modelled crop request from +// turning into an unbounded allocation. +const ( + // maxCropSourceBytes caps the decoded source image body. + maxCropSourceBytes = 50 << 20 + // maxCropPixels caps the requested region's pixel count (~16 MP). + maxCropPixels = 4096 * 4096 + // cropDataURIMax is the largest crop payload the tool will return inline. A + // bigger crop is rejected rather than silently truncated: the model must + // narrow the region, not receive a half image it cannot describe. + cropDataURIMax = 8 << 20 +) + +// cropTool crops an axis-aligned region out of an image and returns it as a +// base64 data URI, so the agent can re-read a zoomed region of a large image with +// the vision or OCR tool (裁决 14.4.5③:「图片按区域裁切」,供模型自行裁大图分区 +// 精读). Path inputs go through the file tool's sandbox — this tool never widens +// file access; URL inputs use the shared SSRF-guarded client. +type cropTool struct { + file *fileTool + client *http.Client + allowedHosts []string + log *slog.Logger +} + +func newCropTool(t *tools, file *fileTool) *cropTool { + return &cropTool{ + file: file, + client: t.client, + allowedHosts: t.allowedHosts, + log: t.log, + } +} + +// Name implements sdk.Tool. +func (c *cropTool) Name() string { return "image-crop" } + +// Description implements sdk.Tool. It names the downsampling reason, because the +// model decides whether to crop from this text alone. +func (c *cropTool) Description() string { + return "Crop a rectangular region out of an image and return the crop (PNG data URI) plus its size. " + + "Use it to read a large image in parts: an image service downsamples big images, so cropping a region " + + "and re-reading the crop recovers the detail. Coordinates are 0-based pixels from the top-left." +} + +// Parameters implements sdk.Tool. +func (c *cropTool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "image": map[string]any{"type": "string", "description": "The image: a local path, an http(s) URL, or a base64/data: URI."}, + "x": map[string]any{"type": "integer", "description": "Left edge of the region, in pixels (0-based)."}, + "y": map[string]any{"type": "integer", "description": "Top edge of the region, in pixels (0-based)."}, + "width": map[string]any{"type": "integer", "description": "Region width in pixels."}, + "height": map[string]any{"type": "integer", "description": "Region height in pixels."}, + }, + "required": []any{"image", "x", "y", "width", "height"}, + }, nil +} + +// Execute implements sdk.Tool. +func (c *cropTool) Execute(ctx context.Context, args any) (any, error) { + m, ok := args.(map[string]any) + if !ok { + return nil, fmt.Errorf("io: image-crop: args must be an object") + } + if err := ctx.Err(); err != nil { + return nil, err + } + src := argString(m, "image") + if strings.TrimSpace(src) == "" { + return nil, fmt.Errorf("io: image-crop: image is required") + } + region, err := cropRegion(m) + if err != nil { + return nil, err + } + data, err := c.load(ctx, src) + if err != nil { + return nil, err + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("io: image-crop: decode image: %w", err) + } + bounds := img.Bounds() + if err := checkRegion(region, bounds); err != nil { + return nil, err + } + cropped := cropToRGBA(img, region) + payload, err := encodePNG(cropped) + if err != nil { + return nil, err + } + c.log.Debug("io image-crop: cropped region", + "source_width", bounds.Dx(), "source_height", bounds.Dy(), + "x", region.Min.X, "y", region.Min.Y, + "width", region.Dx(), "height", region.Dy(), "bytes", len(payload)) + return map[string]any{ + "image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload), + "format": "image/png", + "width": region.Dx(), + "height": region.Dy(), + "region": map[string]any{"x": region.Min.X, "y": region.Min.Y, "width": region.Dx(), "height": region.Dy()}, + "source": map[string]any{"width": bounds.Dx(), "height": bounds.Dy()}, + }, nil +} + +// cropRegion reads and validates the region arguments. A non-positive width or +// height, or a negative offset, is rejected (never clamped: a clamped crop would +// silently hand the model a different region than it asked for). +func cropRegion(m map[string]any) (image.Rectangle, error) { + x, ok := argInt(m, "x") + if !ok { + return image.Rectangle{}, fmt.Errorf("io: image-crop: x must be an integer") + } + y, ok := argInt(m, "y") + if !ok { + return image.Rectangle{}, fmt.Errorf("io: image-crop: y must be an integer") + } + w, ok := argInt(m, "width") + if !ok { + return image.Rectangle{}, fmt.Errorf("io: image-crop: width must be an integer") + } + h, ok := argInt(m, "height") + if !ok { + return image.Rectangle{}, fmt.Errorf("io: image-crop: height must be an integer") + } + if x < 0 || y < 0 { + return image.Rectangle{}, fmt.Errorf("io: image-crop: x and y must be >= 0, got (%d,%d)", x, y) + } + if w <= 0 || h <= 0 { + return image.Rectangle{}, fmt.Errorf("io: image-crop: width and height must be > 0, got (%d,%d)", w, h) + } + if int64(w)*int64(h) > maxCropPixels { + return image.Rectangle{}, fmt.Errorf("io: image-crop: region of %dx%d exceeds the %d-pixel limit", w, h, maxCropPixels) + } + return image.Rect(x, y, x+w, y+h), nil +} + +// checkRegion rejects a region that is not fully inside the image. +func checkRegion(r image.Rectangle, bounds image.Rectangle) error { + if !r.In(bounds) { + return fmt.Errorf("io: image-crop: region %v is outside the image %v", r, bounds) + } + return nil +} + +// cropToRGBA copies the region into a fresh RGBA image so the result does not +// alias the (possibly huge) source and always re-encodes losslessly. +func cropToRGBA(img image.Image, r image.Rectangle) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) + draw.Draw(dst, dst.Bounds(), img, r.Min, draw.Src) + return dst +} + +// encodePNG encodes the crop, refusing a payload above the inline cap so a huge +// crop is reported instead of flooding the tool result. +func encodePNG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("io: image-crop: encode crop: %w", err) + } + if buf.Len() > cropDataURIMax { + return nil, fmt.Errorf("io: image-crop: crop is %d bytes, above the %d-byte inline limit; crop a smaller region", buf.Len(), cropDataURIMax) + } + return buf.Bytes(), nil +} + +// load reads the source image: a local path through the file sandbox, an http(s) +// URL through the shared SSRF-guarded client, or a base64/data: URI payload. +func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) { + switch { + case strings.HasPrefix(src, "data:"): + return decodeCropDataURI(src) + case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"): + return c.fetch(ctx, src) + default: + return c.readFile(src) + } +} + +// decodeCropDataURI decodes a "data:;base64," source. +func decodeCropDataURI(src string) ([]byte, error) { + comma := strings.IndexByte(src, ',') + if comma < 0 || !strings.Contains(src[:comma], ";base64") { + return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)") + } + data, err := base64.StdEncoding.DecodeString(src[comma+1:]) + if err != nil { + return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err) + } + if len(data) > maxCropSourceBytes { + return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes) + } + return data, nil +} + +// readFile reads a path through the file tool's sandbox so cropping can never +// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处). +func (c *cropTool) readFile(p string) ([]byte, error) { + if c.file == nil { + return nil, fmt.Errorf("io: image-crop: no sandbox configured") + } + real, err := c.file.resolveExisting(p) + if err != nil { + return nil, err + } + fh, err := os.Open(real) + if err != nil { + return nil, fmt.Errorf("io: image-crop: open: %w", err) + } + defer fh.Close() + data, truncated, err := readLimited(fh, maxCropSourceBytes) + if err != nil { + return nil, fmt.Errorf("io: image-crop: read: %w", err) + } + if truncated { + return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes) + } + return data, nil +} + +// fetch downloads an http(s) source through the shared client, applying the same +// SSRF guard as the other network tools. +func (c *cropTool) fetch(ctx context.Context, rawURL string) ([]byte, error) { + if c.client == nil { + return nil, fmt.Errorf("io: image-crop: no http client configured") + } + if _, err := validateRequestURL(ctx, rawURL, c.allowedHosts); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("io: image-crop: build request: %w", err) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("io: image-crop: fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("io: image-crop: fetch: status %d", resp.StatusCode) + } + data, truncated, err := readLimited(resp.Body, maxCropSourceBytes) + if err != nil { + return nil, fmt.Errorf("io: image-crop: read response: %w", err) + } + if truncated { + return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes) + } + return data, nil +} diff --git a/plugins/tools/io/crop_test.go b/plugins/tools/io/crop_test.go new file mode 100644 index 0000000..13aff0f --- /dev/null +++ b/plugins/tools/io/crop_test.go @@ -0,0 +1,205 @@ +package io + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// gradientPNG writes an image whose pixel (x,y) encodes its own coordinates in the +// red/green channels. That makes a crop self-verifying: if the crop's top-left +// pixel is not RGBA{sourceX, sourceY, 0, 255} the wrong region was returned. +func gradientPNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png.Encode: %v", err) + } + return buf.Bytes() +} + +// decodeCropResult pulls the PNG out of a crop tool result and decodes it. +func decodeCropResult(t *testing.T, result any) image.Image { + t.Helper() + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("result type = %T, want map[string]any", result) + } + uri, _ := m["image"].(string) + if !strings.HasPrefix(uri, "data:image/png;base64,") { + t.Fatalf("image field = %q, want a PNG data URI", uri) + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,")) + if err != nil { + t.Fatalf("decode data URI: %v", err) + } + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("decode crop: %v", err) + } + return img +} + +func newCropToolForTest(t *testing.T, extra ...Option) (*cropTool, string) { + t.Helper() + dir := t.TempDir() + set := buildTools(t, &config.Config{DataDir: dir}, extra...) + tool, ok := findTool(t, set, "image-crop").(*cropTool) + if !ok { + t.Fatalf("image-crop tool has type %T, want *cropTool", findTool(t, set, "image-crop")) + } + return tool, dir +} + +// TestCropTool_CropsRequestedRegion pins the region arithmetic: the returned crop +// must be exactly the requested region with the requested pixels, not merely the +// right size (裁决 14.4.5③). +func TestCropTool_CropsRequestedRegion(t *testing.T) { + t.Parallel() + tool, dir := newCropToolForTest(t) + path := filepath.Join(dir, "src.png") + if err := os.WriteFile(path, gradientPNG(t, 100, 80), filePerm); err != nil { + t.Fatalf("write source: %v", err) + } + + res, err := tool.Execute(context.Background(), map[string]any{ + "image": path, "x": 10, "y": 20, "width": 30, "height": 40, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + img := decodeCropResult(t, res) + if img.Bounds().Dx() != 30 || img.Bounds().Dy() != 40 { + t.Fatalf("crop size = %dx%d, want 30x40", img.Bounds().Dx(), img.Bounds().Dy()) + } + got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA) + if got.R != 10 || got.G != 20 { + t.Fatalf("top-left pixel = %v, want the source pixel (10,20)", got) + } + last := color.NRGBAModel.Convert(img.At(29, 39)).(color.NRGBA) + if last.R != 39 || last.G != 59 { + t.Fatalf("bottom-right pixel = %v, want the source pixel (39,59)", last) + } +} + +// TestCropTool_RejectsRegionOutsideImage pins fail-loud instead of clamping: a +// clamp would hand the model a different region than it asked for. +func TestCropTool_RejectsRegionOutsideImage(t *testing.T) { + t.Parallel() + tool, dir := newCropToolForTest(t) + path := filepath.Join(dir, "src.png") + if err := os.WriteFile(path, gradientPNG(t, 50, 50), filePerm); err != nil { + t.Fatalf("write source: %v", err) + } + cases := []map[string]any{ + {"image": path, "x": 40, "y": 0, "width": 20, "height": 10}, + {"image": path, "x": 0, "y": 40, "width": 10, "height": 20}, + {"image": path, "x": -1, "y": 0, "width": 10, "height": 10}, + {"image": path, "x": 0, "y": 0, "width": 0, "height": 10}, + {"image": path, "x": 0, "y": 0, "width": 10, "height": -5}, + } + for _, args := range cases { + if _, err := tool.Execute(context.Background(), args); err == nil { + t.Errorf("args %v accepted, want an out-of-bounds error", args) + } + } +} + +// TestCropTool_RejectsPathOutsideSandbox pins the file sandbox reuse: cropping can +// never reach a file the file tool could not read. +func TestCropTool_RejectsPathOutsideSandbox(t *testing.T) { + t.Parallel() + tool, _ := newCropToolForTest(t) + outside := filepath.Join(t.TempDir(), "outside.png") + if err := os.WriteFile(outside, gradientPNG(t, 10, 10), filePerm); err != nil { + t.Fatalf("write outside image: %v", err) + } + if _, err := tool.Execute(context.Background(), map[string]any{ + "image": outside, "x": 0, "y": 0, "width": 2, "height": 2, + }); err == nil { + t.Fatal("path outside the data dir accepted, want a sandbox rejection") + } +} + +// TestCropTool_AcceptsDataURIAndURL pins the other two sources: a data: URI and an +// http(s) URL both decode into a crop. +func TestCropTool_AcceptsDataURIAndURL(t *testing.T) { + t.Parallel() + src := gradientPNG(t, 20, 20) + uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(src) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(src) + })) + t.Cleanup(srv.Close) + + tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv))) + cases := []string{uri, srv.URL + "/img.png"} + for _, source := range cases { + res, err := tool.Execute(context.Background(), map[string]any{ + "image": source, "x": 5, "y": 5, "width": 4, "height": 3, + }) + if err != nil { + t.Fatalf("Execute(%s): %v", source, err) + } + if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 { + t.Fatalf("source %s crop size = %dx%d, want 4x3", source, img.Bounds().Dx(), img.Bounds().Dy()) + } + } +} + +// TestCropTool_RejectsUnsupportedDataURI pins a non-base64 data URI as an error +// rather than a silent empty decode. +func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) { + t.Parallel() + tool, _ := newCropToolForTest(t) + if _, err := tool.Execute(context.Background(), map[string]any{ + "image": "data:image/png,plaintext", "x": 0, "y": 0, "width": 1, "height": 1, + }); err == nil { + t.Fatal("non-base64 data URI accepted, want an error") + } +} + +// TestCropTool_RegisteredInSet pins that the tool ships with the default set and +// advertises a usable schema (the agent only sees it if it is registered). +func TestCropTool_RegisteredInSet(t *testing.T) { + t.Parallel() + tool, _ := newCropToolForTest(t) + if tool.Name() != "image-crop" { + t.Errorf("Name = %q, want image-crop", tool.Name()) + } + params, err := tool.Parameters() + if err != nil { + t.Fatalf("Parameters: %v", err) + } + if params["type"] != "object" { + t.Errorf("schema type = %v, want object", params["type"]) + } + var _ sdk.Tool = tool + // A nil/mis-typed argument set must be an error, not a panic. + if _, err := tool.Execute(context.Background(), "not-a-map"); err == nil { + t.Error("non-object args accepted, want an error") + } + if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil { + t.Error("missing image accepted, want an error") + } +} diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go index 31db291..3d5ff0e 100644 --- a/plugins/tools/io/doc.go +++ b/plugins/tools/io/doc.go @@ -1,7 +1,7 @@ // Package io implements the built-in I/O and network tools for Cyrene v2 -// (DevDocs/design/09-插件生态.md). It exposes file, http, web-search, web-fetch -// and knowledge as pkg/plugins/sdk.Tool values, wired to the process-internal -// config, logger and runtime/memory retriever. +// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, http, web-search, +// web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the +// process-internal config, logger and runtime/memory retriever. // // Build the full configured tool set with New(cfg, opts...). Tools() and // Register(reg) are convenience wrappers that use platform defaults; production @@ -13,6 +13,10 @@ // - file: every path is cleaned and validated to stay inside the configured // data directory or an explicit whitelist; ".." traversal and symlink escape // are rejected; reads and writes are size-capped. +// - image-crop: crops a region out of a large image so the agent can read it in +// parts (an image service downsamples big images); path sources go through the +// file sandbox, URL sources through the SSRF guard, and the returned data URI +// is size-capped. // - http: GET/POST only, timeouts, response truncation and SSRF protection // (loopback/private/link-local addresses and localhost are refused unless // the host is on an explicit allowlist). diff --git a/plugins/tools/io/tools.go b/plugins/tools/io/tools.go index 68c40e8..acf753f 100644 --- a/plugins/tools/io/tools.go +++ b/plugins/tools/io/tools.go @@ -202,8 +202,10 @@ func Register(reg sdk.Registrar) error { // build assembles the tools sharing this set's client, limits and logger. func (t *tools) build() []sdk.Tool { + file := newFileTool(t) return []sdk.Tool{ - newFileTool(t), + file, + newCropTool(t, file), newHTTPTool(t), newWebSearchTool(t), newWebFetchTool(t), diff --git a/plugins/tools/io/tools_test.go b/plugins/tools/io/tools_test.go index 590e363..4dff4e9 100644 --- a/plugins/tools/io/tools_test.go +++ b/plugins/tools/io/tools_test.go @@ -9,16 +9,16 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) -func TestTools_ReturnsAllFiveTools(t *testing.T) { +func TestTools_ReturnsEveryTool(t *testing.T) { t.Parallel() set, err := New(&config.Config{DataDir: t.TempDir()}) if err != nil { t.Fatalf("New: %v", err) } - if len(set) != 5 { - t.Fatalf("expected 5 tools, got %d", len(set)) + if len(set) != 6 { + t.Fatalf("expected 6 tools, got %d", len(set)) } - want := []string{"file", "http", "web-search", "web-fetch", "knowledge"} + want := []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"} seen := map[string]bool{} for _, tool := range set { seen[tool.Name()] = true @@ -53,7 +53,7 @@ func TestRegister_RegistersEveryTool(t *testing.T) { if err := Register(reg); err != nil { t.Fatalf("Register: %v", err) } - for _, name := range []string{"file", "http", "web-search", "web-fetch", "knowledge"} { + for _, name := range []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"} { c, err := reg.Get(name) if err != nil { t.Fatalf("Get(%q): %v", name, err)