Files
Cyrene_v2/internal/runtime/loop/request.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 仍写不出这五个块
2026-10-04 13:07:52 +08:00

196 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
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"
)
// buildRequest 从该 turn 的 agent 公开表面组装 model.Request,
// 镜像 agent.buildRequest,使流式路径的行为与
// 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(),
Messages: assembleMessages(ag, messages),
Temperature: temperatureOf(ag),
MaxTokens: ag.MaxTokens(),
Purpose: ag.Purpose(),
Tools: l.toolSchemas(ctx, messages),
Thinking: knobs.Thinking,
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
}
}
return req
}
// assembleMessages 在 agent 指令非空时把它们作为前导系统消息
// 前置,否则原样返回输入切片。
func assembleMessages(ag *agent.Agent, messages []model.Message) []model.Message {
instr := ag.Instructions()
if instr == "" {
return messages
}
out := make([]model.Message, 0, len(messages)+1)
out = append(out, model.TextMessage(model.RoleSystem, instr))
out = append(out, messages...)
return out
}
// temperatureOf 返回指向 agent 所配置温度的指针,把
// 零映射为 nil,使 provider 默认值生效(与 agent 包一致)。
func temperatureOf(ag *agent.Agent) *float64 {
t := ag.Temperature()
if t == 0 {
return nil
}
return &t
}
// toolSchemas 向模型广告已注册的工具 schema;未配置
// dispatcher 时为 nil。接上并启用了 capacity 判定器时,它
// 判定当前轮次并只返回该状态的允许集
// (dispatcher 之上的一个视图,见 design/14 §4):模型只看见/调用
// 允许的工具,而 Invoke 本身不被拦截。nil 或被禁用的
// 判定器保持既有行为(每个工具都被广告)。
//
// design/11 §3 安全门最后在 capacity 视图留下的结果之上运行:
// 群聊/陌生人环境中只广告其可编辑标记允许该
// 环境的工具(默认关闭),而 owner 环境保留
// 全集。安全是硬红线而非便利提示,因此它
// 只能收缩暴露面 —— 绝不重新放行被 capacity 排除的工具。
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
d := l.dispatcher()
if d == nil {
return nil
}
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return l.restrictSchemas(ctx, d, schemas)
}
names := make([]string, 0, len(schemas))
for _, s := range schemas {
names = append(names, s.Name)
}
acc := capState(ctx)
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),
})
acc.state = dec.State
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
sessionID := loopSession(ctx)
for _, ev := range dec.Events {
ev.SessionID = sessionID
l.onCapacity(ev)
}
}
allowed := make(map[string]bool, len(dec.AllowedTools))
for _, n := range dec.AllowedTools {
allowed[n] = true
}
out := make([]model.ToolSpec, 0, len(dec.AllowedTools))
for _, s := range schemas {
if allowed[s.Name] {
out = append(out, s)
}
}
return l.restrictSchemas(ctx, d, out)
}
// capacityTexts 为 capacity 判定器提取近期对话文本:
// 最后几个非 system/非 tool 的轮次,按时间顺序(最早的在前),
// 使判定器的当前轮窗口最后看到最新的用户消息。
func capacityTexts(messages []model.Message) []string {
texts := make([]string, 0, 6)
for i := len(messages) - 1; i >= 0 && len(texts) < 6; i-- {
m := messages[i]
if m.Role == model.RoleSystem || m.Role == model.RoleTool {
continue
}
if t := messageText(m); t != "" {
texts = append(texts, t)
}
}
for i, j := 0, len(texts)-1; i < j; i, j = i+1, j-1 {
texts[i], texts[j] = texts[j], texts[i]
}
return texts
}
// capacityRecallTopK 限制 capacity L4 层每轮考虑多少条
// 召回的习惯记忆。
const capacityRecallTopK = 5
// capacityRecall 为 capacity L4 习惯对齐层(design §2.2 L4)
// 检索主人的相关长期记忆,这是让判定器学到用户任务表达
// 习惯的记忆输入。它返回 nil(使 L4 保持静默)的情形包括:
// 未配置 retriever/persona —— 向后兼容 —— 或处于受限
// (群聊 / 陌生人)上下文,依据隔离红线:L4 学习主人的
// 习惯,绝不把主人敏感记忆读进群聊或陌生人的意图
// 判定(design §2.2 红线: 不读陌生人敏感)。
func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem {
personaID := l.personaFor(ctx)
if l.retriever == nil || personaID == "" {
return nil
}
env := sessionEnvFrom(ctx)
if env.Restricted() {
return nil
}
results, err := l.retriever.Retrieve(ctx, personaID, queryFromMessages(messages), capacityRecallTopK)
if err != nil {
l.log.Warn("capacity recall", "error", err, "env", env.ActorEnv)
return nil
}
items := make([]capacity.RecallItem, 0, len(results))
for _, r := range results {
items = append(items, capacity.RecallItem{
Content: r.Item.Content,
Scope: string(r.Item.Scope),
Kind: string(r.Item.Kind),
Tags: r.Item.Tags,
Score: r.Score,
})
}
return items
}
// decodeToolCalls 经 agent 的共享解码器从 model.Response 解码工具
// 调用,使流式与非流式路径对适配器发出的 provider 原生
// 工具调用(Anthropic tool_use / OpenAI tool_calls)以及
// 旧式 JSON 信封约定保持一致。解码出调用时
// 报告 ok=true(切片非空)。
func decodeToolCalls(resp model.Response) ([]agent.ToolCall, bool) {
return agent.ExtractToolCalls(resp)
}