fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx) → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处) · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构 · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致) - **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉 awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。 新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障) - 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更) - 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop 流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同); **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑) - 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略, 需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
This commit is contained in:
1 parent
58cba0b8d2
commit
7afef8d037
11 files changed
+778
-142
No files matched your search
@@ -0,0 +1,85 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定裁决 14.4.3 后半在**循环**这一层的契约:唤醒档位按该 turn 的角色
|
||||
// 解析(Options.PersonaAwakening),而不是绑定在 Loop 构造期;未注入解析器或
|
||||
// 角色未知时一律零覆盖、回落到判定器的全局兜底 —— 即接线前的行为。
|
||||
|
||||
// lastStreamTools 返回最新一次流式请求广告给模型的工具名集合。
|
||||
func lastStreamTools(t *testing.T, ad *mockAdapter) map[string]bool {
|
||||
t.Helper()
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatal("adapter recorded no streaming request")
|
||||
}
|
||||
return toolSet(ad.streamReqs[len(ad.streamReqs)-1].Tools)
|
||||
}
|
||||
|
||||
// TestLoop_CapacityAwakeningFollowsTheTurnRole 断言同一句「意图各层都拿不准」的
|
||||
// 输入下("这个"),声明了档位的角色唤醒、没声明的角色静息,而**未知角色**与
|
||||
// **未注入解析器**两种退化都保持判定器的既有行为(静息,且不广告 web-search)。
|
||||
func TestLoop_CapacityAwakeningFollowsTheTurnRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
td := capacityToolSet(t)
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
|
||||
a := newTestAgent(t, ad, td)
|
||||
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
|
||||
awakened := map[string]capacity.Awakening{"Y": {ForceState: capacity.StateAwake}}
|
||||
newLoop := func(resolve func(string) capacity.Awakening) *Loop {
|
||||
return New(Options{
|
||||
Agent: a,
|
||||
PersonaID: "X",
|
||||
Capacity: dec,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
PersonaAwakening: resolve,
|
||||
})
|
||||
}
|
||||
const ambiguous = "这个"
|
||||
|
||||
resolved := newLoop(func(id string) capacity.Awakening { return awakened[id] })
|
||||
runPersonaTurn(t, resolved, roleEnv("X"), "web:x1", ambiguous)
|
||||
if got := lastStreamTools(t, ad); got["web-search"] {
|
||||
t.Fatalf("无档位的角色 X 的工具面 = %v, want 静息(不得广告 web-search)", got)
|
||||
}
|
||||
runPersonaTurn(t, resolved, roleEnv("Y"), "web:y1", ambiguous)
|
||||
if got := lastStreamTools(t, ad); !got["web-search"] {
|
||||
t.Fatalf("声明 force_state=awake 的角色 Y 的工具面 = %v, want 唤醒", got)
|
||||
}
|
||||
// 未知角色(解析器不认识它)不得让该 turn 失败,也不得泄漏别人的档位。
|
||||
runPersonaTurn(t, resolved, roleEnv("nobody"), "web:z1", ambiguous)
|
||||
if got := lastStreamTools(t, ad); got["web-search"] {
|
||||
t.Fatalf("未知角色的工具面 = %v, want 静息(按无覆盖处理)", got)
|
||||
}
|
||||
|
||||
// 未注入解析器 = 接线前的形状:所有角色都走全局兜底。
|
||||
before := newLoop(nil)
|
||||
runPersonaTurn(t, before, roleEnv("Y"), "web:y2", ambiguous)
|
||||
if got := lastStreamTools(t, ad); got["web-search"] {
|
||||
t.Fatalf("未注入解析器时角色 Y 的工具面 = %v, want 与接线前一致(静息)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_AwakeningForDegradesQuietly 覆盖解析器的两个降级口:未注入解析器、
|
||||
// 以及该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空)时,
|
||||
// awakeningFor 一律返回零覆盖而不是 panic 或猜一个角色。
|
||||
func TestLoop_AwakeningForDegradesQuietly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plain := WithSessionEnv(context.Background(), OwnerEnv())
|
||||
if got := New(Options{}).awakeningFor(plain); !got.IsZero() {
|
||||
t.Fatalf("未注入解析器的循环返回了 %+v, want 零覆盖", got)
|
||||
}
|
||||
l := New(Options{PersonaAwakening: func(string) capacity.Awakening {
|
||||
return capacity.Awakening{ForceState: capacity.StateAwake}
|
||||
}})
|
||||
if got := l.awakeningFor(plain); !got.IsZero() {
|
||||
t.Fatalf("没有角色的 turn 返回了 %+v, want 零覆盖(不猜角色)", got)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ type Loop struct {
|
||||
personaID string
|
||||
personaName func(personaID string) string
|
||||
personaKnobs func(personaID string) (LoopKnobs, bool)
|
||||
personaAwakening func(personaID string) capacity.Awakening
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
maxRounds int
|
||||
@@ -160,6 +161,7 @@ func New(opts Options) *Loop {
|
||||
personaID: opts.PersonaID,
|
||||
personaName: opts.PersonaName,
|
||||
personaKnobs: opts.PersonaKnobs,
|
||||
personaAwakening: opts.PersonaAwakening,
|
||||
timeLocation: timeLocation,
|
||||
timeZoneName: timeZoneName,
|
||||
maxRounds: maxRounds,
|
||||
|
||||
@@ -109,6 +109,16 @@ type Options struct {
|
||||
// 输入落到正确角色旋钮的原因。为 nil 时每个 turn 都保持配置的
|
||||
// 默认值,即多角色之前的行为。
|
||||
PersonaKnobs func(personaID string) (LoopKnobs, bool)
|
||||
// PersonaAwakening 非 nil 时,为该 turn 的角色解析唤醒档位
|
||||
// (design/14 §3,裁决 14.4.3),使绑定到角色 Y 的会话按 Y 档案里的
|
||||
// `awakening` 块判定能力面 —— 而不是只吃默认角色的全局兜底
|
||||
// (Capacity 的 Config.Awakening)。没有它,别的角色写了档位也会
|
||||
// 「被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效的配置)。
|
||||
// 返回零值 = 该角色无覆盖,回落到 Config.Awakening 与全局阈值(档案里
|
||||
// 没写该块就是没有)。与 PersonaKnobs 同样经角色 id 解析,因此所有路径
|
||||
// 都由同一个输入落到正确角色的档位。为 nil 时每个 turn 都不传逐调用覆盖,
|
||||
// 即接线前的行为。
|
||||
PersonaAwakening func(personaID string) capacity.Awakening
|
||||
// Timezone 是用于渲染每轮「当前时间」系统消息的 IANA
|
||||
// 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入
|
||||
// (向后兼容);加载失败的名字同样关闭它。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"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/model"
|
||||
)
|
||||
|
||||
@@ -13,11 +14,11 @@ import (
|
||||
// 记忆命名空间、情绪机与推理旋钮 —— 改为从该 turn 的
|
||||
// SessionEnv 解析。
|
||||
//
|
||||
// 解析刻意做成 ctx 上的三个小函数,而不是 Loop 上的
|
||||
// 解析刻意做成 ctx 上的几个小函数,而不是 Loop 上的
|
||||
// 字段:Loop 由所有并发会话共享,把解析出的角色存在
|
||||
// 它上面会产生数据竞争,而 ctx 本就携带按 turn 的 env。
|
||||
// 所有角色作用域读取都必须走这些辅助函数,使提示词、记忆、
|
||||
// 情绪与思考永远不会对「谁在回答」产生分歧。
|
||||
// 情绪、思考与唤醒档位永远不会对「谁在回答」产生分歧。
|
||||
|
||||
// LoopKnobs 是注入循环构建的每个请求的按角色模型/推理
|
||||
// 旋钮。它镜像三个构造期选项
|
||||
@@ -36,8 +37,8 @@ type LoopKnobs struct {
|
||||
|
||||
// personaFor 返回当前 turn 发言所作为的角色(design/22 §2):调用方
|
||||
// 解析出角色时取会话环境的显式角色,否则取循环
|
||||
// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆
|
||||
// 与情绪钩子都读它,因此一个 turn 绝不会混用两个角色。
|
||||
// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆、
|
||||
// 情绪钩子与唤醒档位都读它,因此一个 turn 绝不会混用两个角色。
|
||||
func (l *Loop) personaFor(ctx context.Context) string {
|
||||
if pid := sessionEnvFrom(ctx).PersonaID; pid != "" {
|
||||
return pid
|
||||
@@ -84,3 +85,27 @@ func (l *Loop) knobsFor(ctx context.Context) LoopKnobs {
|
||||
}
|
||||
return knobs
|
||||
}
|
||||
|
||||
// awakeningFor 解析当前 turn 的唤醒档位(design/14 §3,裁决 14.4.3):该 turn
|
||||
// 所属角色在档案里声明的 `awakening` 块,映射成决策层的人格覆盖。档位随 turn
|
||||
// 解析而不是在 Loop 构造期绑定,理由与提示词/记忆/旋钮相同 —— 一个进程同时
|
||||
// 承载多个角色,而 Loop 由所有并发会话共享(存在 Loop 上会产生数据竞争)。
|
||||
// 解析器由组合根注入(Options.PersonaAwakening,按角色 id 查那份 YAML),
|
||||
// 与 knobsFor 一样按角色 id 解析,因此所有路径(网关、channelbridge、
|
||||
// backthink、子代理)都从同一个输入落到正确角色的档位。
|
||||
//
|
||||
// 下列情形一律返回零值 = **无覆盖**,由判定器回落到 Config.Awakening(全局
|
||||
// 兜底)与全局阈值,绝不在此处凭空补档位:
|
||||
// - 未注入解析器(单角色接线、或未接线的调用方)——逐字节等于接线前的行为;
|
||||
// - 该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空);
|
||||
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
|
||||
func (l *Loop) awakeningFor(ctx context.Context) capacity.Awakening {
|
||||
if l.personaAwakening == nil {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
pid := l.personaFor(ctx)
|
||||
if pid == "" {
|
||||
return capacity.Awakening{}
|
||||
}
|
||||
return l.personaAwakening(pid)
|
||||
}
|
||||
@@ -98,6 +98,10 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
dec := l.capacity.Decide(ctx, capacity.Input{
|
||||
Messages: capacityTexts(messages),
|
||||
CurrentState: acc.state,
|
||||
// 该 turn 所属角色的档位(裁决 14.4.3 的后半):没有它,绑定非默认
|
||||
// 角色的会话只能吃到默认角色的全局兜底,别的角色写在档案里的
|
||||
// `awakening` 会被解析、被校验、却静默不生效。
|
||||
Awakening: l.awakeningFor(ctx),
|
||||
AllToolNames: names,
|
||||
Recall: l.capacityRecall(ctx, messages),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user