fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
根因(agent-191,均给出文件:行): 1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给 memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。 修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取 「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。 2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 / 轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。 修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因 的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度) 仍不落库,真实文本行为逐字未改。 测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条) 变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
This commit is contained in:
1 parent
3be70f8232
commit
18381f65fd
9 files changed
+838
-11
No files matched your search
@@ -54,6 +54,12 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// Dispatcher.Invoke 据此做前置软判。作用域同样限于本次 Run,
|
||||
// 子代理 Run 各自播种一份,并发 run 绝不共享。
|
||||
ctx = tool.WithRoundAllowSet(ctx)
|
||||
// 同时把本回合**角色**发布给工具(tool/turncontext.go):knowledge 这类
|
||||
// 需要角色作用域的工具据此查到「以谁的名义」检索。不发布的话,模型省略
|
||||
// 可选参数 persona 时工具会把空角色透给记忆存储,换回一条它无从纠正的
|
||||
// ErrMissingPersona(切片 KB1 的真因)。取值与提示词/记忆同源(personaFor),
|
||||
// 因此工具看到的就是本轮说话的角色,不会出现两个角色。
|
||||
ctx = tool.WithTurnPersona(ctx, l.personaFor(ctx))
|
||||
ctx = withLoopSession(ctx, sessionID)
|
||||
|
||||
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 KB1 的 loop 侧一半:**本回合角色必须发布给工具**。
|
||||
//
|
||||
// 复现的形状:模型省略 knowledge 工具的可选参数 persona,工具此前把空角色透给
|
||||
// 记忆存储,后者以 ErrMissingPersona 硬失败 —— 模型既不知道该填哪个角色 id,
|
||||
// 参数说明还写着 optional,于是连撞同一堵墙。修复是循环在 Run 顶部把 personaFor
|
||||
// 解析出的角色经 tool.WithTurnPersona 发布到本轮 ctx 上,工具据此查到「以谁的
|
||||
// 名义」检索。
|
||||
//
|
||||
// 断言走真路径:真 Run → 真 tool.Dispatcher.Invoke → 真工具 Execute 读 ctx。
|
||||
// 工具看到的值必须与提示词/记忆同源(personaFor):会话环境有显式角色时取它,
|
||||
// 否则取循环配置的默认角色。
|
||||
|
||||
// personaProbeTool 记录 Execute 时在 ctx 上读到的本回合角色。
|
||||
type personaProbeTool struct {
|
||||
got []string
|
||||
}
|
||||
|
||||
func (p *personaProbeTool) Name() string { return "persona_probe" }
|
||||
func (p *personaProbeTool) Description() string { return "记录本回合角色" }
|
||||
func (p *personaProbeTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
|
||||
func (p *personaProbeTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
p.got = append(p.got, tool.TurnPersonaFrom(ctx))
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// runPersonaProbe 跑一个「一次工具调用 + 收尾」的回合,返回工具在 ctx 上读到的角色。
|
||||
func runPersonaProbe(t *testing.T, ctx context.Context, configured string) string {
|
||||
t.Helper()
|
||||
call := `{"tool_calls":[{"name":"persona_probe","args":{}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "收尾"}},
|
||||
}}
|
||||
probe := &personaProbeTool{}
|
||||
td := dispatcherWith(t, probe)
|
||||
a := newTestAgent(t, ad, td)
|
||||
l := New(Options{Agent: a, MaxRounds: 8, PersonaID: configured})
|
||||
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "查一下")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(probe.got) != 1 {
|
||||
t.Fatalf("工具执行次数 = %d, want 1", len(probe.got))
|
||||
}
|
||||
return probe.got[0]
|
||||
}
|
||||
|
||||
// TestRun_PublishesTurnPersonaToTools 断言:会话环境给了显式角色时,工具看到的是
|
||||
// 那个角色(而不是循环配置的默认角色)。
|
||||
func TestRun_PublishesTurnPersonaToTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := OwnerEnv()
|
||||
env.PersonaID = "persona-x"
|
||||
ctx := WithSessionEnv(context.Background(), env)
|
||||
if got := runPersonaProbe(t, ctx, "default-persona"); got != "persona-x" {
|
||||
t.Fatalf("工具读到的本回合角色 = %q, want persona-x(应与提示词/记忆同源)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_PublishesConfiguredPersonaWhenEnvEmpty 断言:会话环境没给角色
|
||||
// (channelbridge / backthink 等既有调用方)时,工具看到的是循环配置的默认角色 ——
|
||||
// 正是「空角色不许透给下游」的兜底。
|
||||
func TestRun_PublishesConfiguredPersonaWhenEnvEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := runPersonaProbe(t, context.Background(), "default-persona"); got != "default-persona" {
|
||||
t.Fatalf("工具读到的本回合角色 = %q, want default-persona", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tool
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件承载「本回合角色」的 context 通道(切片 KB1)。
|
||||
//
|
||||
// 背景:一个 Run 里每轮要回答的**角色**由循环解析(loop 的 personaFor:
|
||||
// 会话环境的显式角色,否则循环配置的默认角色),提示词、记忆注入与情绪
|
||||
// 都按它取值。工具侧此前拿不到这个角色,于是 knowledge 工具把可选参数
|
||||
// `persona` 缺席时的空串直接透给记忆检索器,由后者以 ErrMissingPersona
|
||||
// 硬失败 —— 模型既不知道该填哪个角色 id(参数说明还写着 optional),
|
||||
// 也就只能对同一堵墙反复重试(安卓侧实测的连续两次
|
||||
// 「工具调用失败 · knowledge」)。
|
||||
//
|
||||
// 通道形态与 allowset.go 同族:循环在 Run 顶部播种一次,工具经
|
||||
// Dispatcher.Invoke 的 ctx(它由本轮 ctx 派生)读取。身份与权限**不**
|
||||
// 走这条通道 —— 它只是「本回合以谁的名义说话」这个作用域元数据,与
|
||||
// SessionEnv 的注释同一纪律(项目红线:身份由 actor 元数据 + 策略引擎
|
||||
// 决定,绝不从这里推断)。
|
||||
|
||||
// turnPersonaKey 是本回合角色的 context key。
|
||||
type turnPersonaKey struct{}
|
||||
|
||||
// WithTurnPersona 把本回合角色挂到 ctx 上(循环在 Run 顶部调用一次)。
|
||||
// 空角色是空操作:不挂载即「本回合没有解析出角色」,读取侧返回空串,
|
||||
// 让依赖角色作用域的工具据此给出可消化的拒绝,而不是把空角色透给
|
||||
// 下游存储换回一条无从纠正的错误。
|
||||
func WithTurnPersona(ctx context.Context, personaID string) context.Context {
|
||||
if ctx == nil || personaID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, turnPersonaKey{}, personaID)
|
||||
}
|
||||
|
||||
// TurnPersonaFrom 返回 ctx 上挂载的本回合角色;未挂载(或 ctx 为 nil)时
|
||||
// 返回空串。
|
||||
func TurnPersonaFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := ctx.Value(turnPersonaKey{}).(string)
|
||||
return s
|
||||
}
|
||||
Reference in new issue
Block a user