Files
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

98 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住非流式路径(agent.RunStep)的 persona 钩子(裁决 03.4.1):它必须与
// 流式主路径(loop.buildRequest 的 RouteRequestForPersona)同源。角色由适配器视图
// 携带(model.PersonaScoped),因为它不经 ctx 也能到达这里,且一角色一 agent 的装配
// 方式(design/22 §4.2 方案 A)本来就把它定在装配处。
// personaScopedAdapter 是带角色的 mockAdapter 视图,模拟 model.PersonaStack:
// RunStep 应据此把角色写进请求,并按角色路由模型名。
type personaScopedAdapter struct {
mockAdapter
persona string
}
// ScopedPersona 实现 model.PersonaScoped。
func (p *personaScopedAdapter) ScopedPersona() string { return p.persona }
// TestRunStep_PersonaScopedAdapterRoutesByPersona 断言:带角色的适配器视图让非流式
// 路径按 persona 路由(而不是按消息 role),并把角色带到请求上,供模型层选
// persona 的协议链(models.json personaProviders)。
func TestRunStep_PersonaScopedAdapterRoutesByPersona(t *testing.T) {
t.Parallel()
router := model.NewRouter("default",
model.WithRoleModel(model.RoleUser, "user-model"),
model.WithPersonaModel("cyrene", "persona-model"),
)
ad := &personaScopedAdapter{
mockAdapter: mockAdapter{resp: model.Response{Model: "persona-model", Content: "ok"}},
persona: "cyrene",
}
a := newTestAgent(t, ad, nil, WithModel(ad, router))
if _, err := a.RunStep(context.Background(), "s",
[]model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if ad.gotReq.PersonaID != "cyrene" {
t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID)
}
if ad.gotReq.Model != "persona-model" {
t.Fatalf("req.Model = %q, want persona-model (persona binding wins over the message role)",
ad.gotReq.Model)
}
}
// TestRunStep_PersonaStampedWithoutRouter asserts the persona travels with the
// adapter view even when no router is injected — the carrier must not depend on
// the routing decision, or an adapter-only wiring would silently lose it.
func TestRunStep_PersonaStampedWithoutRouter(t *testing.T) {
t.Parallel()
ad := &personaScopedAdapter{
mockAdapter: mockAdapter{resp: model.Response{Content: "ok"}},
persona: "cyrene",
}
a := newTestAgent(t, ad, nil, WithModel(ad, nil))
if _, err := a.RunStep(context.Background(), "s",
[]model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if ad.gotReq.PersonaID != "cyrene" {
t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID)
}
}
// TestRunStep_UnknownPersonaKeepsLegacyRouting 断言未绑定角色的 agent 行为不变:
// 没有角色就继续走原来的 RouteRequest,其中包含「有图像 → 换视觉模型」这条老规则
// (RouteRequestForPersona 故意不含它,图像路由已归 vision.Router)。这条断言防止
// 把两代路由语义混在一起。
func TestRunStep_UnknownPersonaKeepsLegacyRouting(t *testing.T) {
t.Parallel()
router := model.NewRouter("default",
model.WithRoleModel(model.RoleUser, "user-model"),
model.WithVisionModel("vision-model"),
)
ad := &mockAdapter{resp: model.Response{Model: "vision-model", Content: "ok"}}
a := newTestAgent(t, ad, nil, WithModel(ad, router))
msg := model.ImageMessage(model.RoleUser, "what is this", "data:image/png;base64,abc")
if _, err := a.RunStep(context.Background(), "s", []model.Message{msg}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if ad.gotReq.PersonaID != "" {
t.Fatalf("req.PersonaID = %q, want empty for an unbound agent", ad.gotReq.PersonaID)
}
if ad.gotReq.Model != "vision-model" {
t.Fatalf("req.Model = %q, want vision-model (the legacy image routing must survive)",
ad.gotReq.Model)
}
}