feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造); 同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 / Codex Queue·Steer / open-webui / LangGraph)。 - 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始): driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进, 故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**) - **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净); 被注入的消息从 pending 移除,不会由自己的回合再答一次 - **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx; 边界序列有测试钉死(恰为 [1 2 …],无批次中途值) - 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库, 按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列, 任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存) - 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」 (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus - 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂 - 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化) - 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E; **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2]; 把帧加进白名单 → viewer 收到)均已还原 - 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
This commit is contained in:
1 parent
8c6c796a29
commit
3d8822c922
9 files changed
+1306
-22
No files matched your search
@@ -29,11 +29,11 @@ type allowedSessions func(sessionID string) bool
|
||||
// 而是把其 span 发布到所属会话下,因此子代理渲染为
|
||||
// 父图的一条泳道,而不是第二张图(design/19 §2)。
|
||||
type FlowPublisher struct {
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex
|
||||
publish func(subject string, payload any)
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
rounds map[string]int
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
rounds map[string]int
|
||||
}
|
||||
|
||||
// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在
|
||||
@@ -84,6 +84,31 @@ func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点打 steered:true 标记」)。
|
||||
// 它让链路图如实表达「这一轮的输入在回合中途被追加过」——图仍然只有一条主干
|
||||
// (I1:同会话恒只有一个在飞回合),而不是被画成第二轮。
|
||||
//
|
||||
// nil 接收者、空会话、发布白名单外的会话、或该会话还没有图时都是空操作;标记
|
||||
// 只并入既有 input 节点(Upsert 的合并语义),绝不新建节点、绝不改动它的标签。
|
||||
func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||||
if p == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.Upsert(trace.NodeSpec{
|
||||
ID: "input",
|
||||
Kind: "input",
|
||||
Props: map[string]any{"steered": true},
|
||||
})
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// SpanStarted 为一个刚开始的 span 记录一个运行中的占位
|
||||
// 节点。state_transition span 会被图构建器省略。
|
||||
func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||||
|
||||
@@ -85,6 +85,12 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Message, startStep int, res *Result) (*Result, error) {
|
||||
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
||||
step := startStep
|
||||
// steered 累积本回合在**回合内边界**注入的用户消息(design/15 §2.4,裁决
|
||||
// 13.4.5)。它们只并入每一步的模型可见视图,绝不写回 msgs —— checkpoint 快照
|
||||
// 与后台记忆提取因此保持干净;又因为每个后续 step 都重新并入,注入内容对模型
|
||||
// 而言始终是**同一个窗口**的一部分(一致性),后到的消息也绝不会顶掉旧窗口
|
||||
// 已产出的内容(完整性,I1)。
|
||||
var steered []model.Message
|
||||
// 未落定退出的回合不留下「可恢复的在途态」(裁决 13.4.5:下一条消息必须
|
||||
// 看到自己的最新上下文,而不是被放弃回合的旧窗口顶掉)。判别口径(主人
|
||||
// 2026-10-04 裁决,「只有崩溃才允许恢复」):**崩溃**时本 defer 根本不执行,
|
||||
@@ -111,7 +117,15 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
|
||||
// 然后为本轮组装模型可见上下文(全新记忆
|
||||
// 注入 + transform 钩子),使每一步都看到最新上下文。
|
||||
msgs = l.compactIfNeeded(ctx, msgs)
|
||||
// 回合内边界(design/15 §2.0 I3):上一个模型步与它那一批工具调用都已结束、
|
||||
// 下一次模型调用还没开始 —— 此刻没有任何工具在飞,因此在这里注入不可能取消
|
||||
// 任何已发起的工具;新到的用户消息则对**同一回合**的下一个模型步立即可见
|
||||
//(design/15 §2.4 最新性)。循环绝不因此新开第二个回合(I1)。
|
||||
steered = l.steerAtBoundary(ctx, sessionID, step, steered)
|
||||
view := l.assembleContext(ctx, msgs, step)
|
||||
if len(steered) > 0 {
|
||||
view = append(view, steered...)
|
||||
}
|
||||
|
||||
rc, roundSpan := l.span(ctx, "loop.step", trace.SpanKindStateTransition)
|
||||
outcome, err := l.step(rc, sessionID, view)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件实现「回合内转向注入」(design/15 §2.0/§2.4,裁决 13.4.5 目标态)。
|
||||
//
|
||||
// 不变量按 design/15 §2.0 实现,语义不在循环里自创:
|
||||
//
|
||||
// - **I1(同会话在途回合数恒为 1)**:转向只在**同一回合的边界**注入,绝不取消
|
||||
// 当前回合、也绝不新开第二个回合。循环对这一点的保证是结构性的——它没有任何
|
||||
// 「起新回合」的分支,注入只是把消息并进当前回合下一次模型调用的视图。
|
||||
// - **I2(消息恰好被处理一次)**:注入=已消费。循环看不到消息 id
|
||||
// (model.Message 没有 id 字段),因此「按 created_at + id 去重」与「从待处理
|
||||
// 队列移除」都归 SteerSource 的实现方(网关会话编排器,它同时持有转录与
|
||||
// pending 队列)。循环只保证同一个边界只问一次、按返回顺序追加。
|
||||
// - **I3(转向不取消已发起的工具)**:注入点只在**回合内边界**——一次模型步结束
|
||||
// 与它那一批工具调用结束**之后**、下一次模型调用**之前**。此刻没有任何工具在
|
||||
// 飞,因此注入不可能取消任何已发起的工具;正在执行的工具永远跑完(与 Cline
|
||||
// PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。
|
||||
//
|
||||
// 为什么注入只进**本轮视图**而不写回运行窗口:写回会让注入内容进入 checkpoint
|
||||
// 快照与后台记忆提取的输入面(两者都读该回合的 msgs),而它们本应只看到该回合真正
|
||||
// 的输入与工具结果。注入在每一步重新并入视图,因此对模型而言它仍是**同一个窗口**
|
||||
// (一致性=同一窗口,design/15 §2.4),只是不落盘、不出现在记忆面。
|
||||
|
||||
// SteerRequest 是循环在回合内边界向 SteerSource 发起的一次「重读转录」请求。
|
||||
type SteerRequest struct {
|
||||
// SessionID 是本次 Run 驱动的会话。
|
||||
SessionID string
|
||||
// Step 是**即将开始**的模型步序号(从 1 开始):被返回的消息会出现在该步的
|
||||
// 模型可见视图里,因此它就是网关 `steer_ack` 帧上报的 `atStep`。
|
||||
Step int
|
||||
}
|
||||
|
||||
// SteerSource 由调用方(网关会话编排器)提供,在指定的回合内边界重读该会话转录,
|
||||
// 返回「本回合开始后新到、且尚未被本回合吃进」的用户消息,供循环追加进本轮视图。
|
||||
// 返回空切片表示本次边界没有可注入的消息,是正常情形而非错误。
|
||||
//
|
||||
// 实现方负责去重与「已消费」口径(见文件头 I2):循环按返回顺序原样追加,不做也
|
||||
// 无法做 id 去重。它必须是非阻塞的——它在 Run 路径上被调用;网络/存储读取有界,
|
||||
// 读失败时返回空(该消息留在待处理队列,照旧作为自己的回合被回答,绝不静默丢失)。
|
||||
type SteerSource interface {
|
||||
Steer(ctx context.Context, req SteerRequest) []model.Message
|
||||
}
|
||||
|
||||
// steerSourceKey 是回合内转向源的 context key。
|
||||
type steerSourceKey struct{}
|
||||
|
||||
// WithSteerSource 把转向源挂到 ctx 上,使本次 Run 的每个回合内边界都能重读转录并
|
||||
// 注入新到的用户消息(design/15 §2.4)。调用方(网关编排器)在**每个回合启动之前**
|
||||
// 挂一份新的源:转向游标是按回合的状态,跨回合复用会让上一个回合已吃进的消息被
|
||||
// 过滤掉或重复注入。
|
||||
//
|
||||
// nil 源是空操作,ctx 原样返回——未接线的调用方(子代理、后台思考、未接网关的循环)
|
||||
// 行为逐字不变。
|
||||
func WithSteerSource(ctx context.Context, src SteerSource) context.Context {
|
||||
if ctx == nil || src == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, steerSourceKey{}, src)
|
||||
}
|
||||
|
||||
// SteerSourceFrom 返回 ctx 上挂的转向源,未挂载时为 nil。它是 WithSteerSource 的
|
||||
// 读侧,供拿到 context 的调用方(网关测试、断言注入边界的实现)观察本次 Run 的
|
||||
// 注入能力;nil 表示本次运行不做任何转向注入。
|
||||
func SteerSourceFrom(ctx context.Context) SteerSource {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
src, _ := ctx.Value(steerSourceKey{}).(SteerSource)
|
||||
return src
|
||||
}
|
||||
|
||||
// steerAtBoundary 在一个回合内边界重读该会话转录,并把新到的用户消息合并进本回合
|
||||
// 已注入列表。未接线转向源、ctx 已取消、或本次边界无新消息时,它原样返回 steered
|
||||
// (无新消息是常态:工具批次之间通常没有用户插话)。
|
||||
//
|
||||
// 它绝不返回错误:转向是增益,任何一个边界的读取失败都不该让用户已经在跑的回合
|
||||
// 失败——失败时返回空(见 SteerSource 契约),该消息留在待处理队列里等下一个回合。
|
||||
func (l *Loop) steerAtBoundary(ctx context.Context, sessionID string, step int, steered []model.Message) []model.Message {
|
||||
src := SteerSourceFrom(ctx)
|
||||
if src == nil || ctx.Err() != nil {
|
||||
return steered
|
||||
}
|
||||
msgs := src.Steer(ctx, SteerRequest{SessionID: sessionID, Step: step})
|
||||
if len(msgs) == 0 {
|
||||
return steered
|
||||
}
|
||||
// 注入的图片消息走与回合开始处同一条视觉改写(design/06 §0):否则一条中途
|
||||
// 注入的图片会绕过 resolver,直接落到不支持视觉的主模型面前。
|
||||
msgs = l.resolveImages(ctx, msgs)
|
||||
// 链路图如实标记「本轮输入被中途追加过」(design/15 §5)。子代理 Run 不拥有
|
||||
// 自己的图(它的 span 并入派生它的会话),因此分支 Run 不打这个标记。
|
||||
if _, isBranch := flowBranch(ctx); !isBranch {
|
||||
l.flow.MarkInputSteered(sessionID)
|
||||
}
|
||||
l.log.Info("steer injected", "session", sessionID, "step", step, "messages", len(msgs))
|
||||
return append(steered, msgs...)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件锁住裁决 13.4.5 的**目标态**在 loop 侧的三条不变量(design/15 §2.0/§2.4):
|
||||
//
|
||||
// I1 同会话在途回合数恒为 1:忙时新消息在**下一个回合内边界**被吃进**同一回合**
|
||||
// (模型在该步看到的窗口里有它),循环绝不因此新开第二个回合;
|
||||
// I2 恰好被处理一次:注入只并进本轮视图,不写回运行窗口 —— checkpoint 快照与
|
||||
// 记忆提取面因此看不到它,它也不会被当成新输入再答一遍;
|
||||
// I3 转向不取消已发起的工具:注入点只在**一批工具调用结束之后**、下一次模型调用
|
||||
// 之前;正在执行的工具跑完,其结果仍在窗口里。
|
||||
//
|
||||
// 这些测试用真的 mockAdapter/mockTool 装配真循环,断言的是**模型实际收到的消息**
|
||||
// 与**真的落下的 checkpoint/提取面**,不是内部字段的回读。
|
||||
|
||||
// steerTrace 是工具与转向源共享的**加锁**事件序:I3 要证明的「工具跑完 → 边界注入」
|
||||
// 这个先后关系,在 Windows 上无法用 time.Now() 证明(粒度粗到两者拿到同一时间戳),
|
||||
// 只能靠共享的单调事件序;加锁是为了让 -race 也保持干净。
|
||||
type steerTrace struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (t *steerTrace) add(ev string) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.events = append(t.events, ev)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *steerTrace) snapshot() []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return append([]string(nil), t.events...)
|
||||
}
|
||||
|
||||
// steerScript 是一份脚本化转向源:按 step 返回要注入的消息,并记录被问到的边界。
|
||||
type steerScript struct {
|
||||
mu sync.Mutex
|
||||
at map[int][]model.Message
|
||||
steps []int
|
||||
returned [][]model.Message
|
||||
// trace 非 nil 时把「真的注入了」记进共享事件序。
|
||||
trace *steerTrace
|
||||
}
|
||||
|
||||
func newSteerScript(at map[int][]model.Message) *steerScript {
|
||||
return &steerScript{at: at}
|
||||
}
|
||||
|
||||
// withTrace 让本转向源把「真的注入了」记进共享事件序。
|
||||
func (s *steerScript) withTrace(tr *steerTrace) *steerScript {
|
||||
s.trace = tr
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *steerScript) Steer(_ context.Context, req SteerRequest) []model.Message {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.steps = append(s.steps, req.Step)
|
||||
msgs := s.at[req.Step]
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.returned = append(s.returned, msgs)
|
||||
s.trace.add("steer:" + messageText(msgs[0]))
|
||||
return msgs
|
||||
}
|
||||
|
||||
// askedSteps 返回转向源被问到的边界序号序列。
|
||||
func (s *steerScript) askedSteps() []int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]int(nil), s.steps...)
|
||||
}
|
||||
|
||||
// boundaryTool 是一个记录自己是否完整跑完、以及 ctx 是否在运行中被取消的工具,
|
||||
// 供 I3 断言「注入没有取消已发起的工具」。
|
||||
type boundaryTool struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
// canceled 记录这次调用是否因 ctx 在运行中被取消而中断。
|
||||
canceled bool
|
||||
delay time.Duration
|
||||
// trace 非 nil 时在完整跑完之际记下 "tool:done"。
|
||||
trace *steerTrace
|
||||
}
|
||||
|
||||
func (b *boundaryTool) Name() string { return "slow" }
|
||||
func (b *boundaryTool) Description() string { return "slow tool for steering tests" }
|
||||
func (b *boundaryTool) Parameters() (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *boundaryTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
if b.delay > 0 {
|
||||
select {
|
||||
case <-time.After(b.delay):
|
||||
case <-ctx.Done():
|
||||
b.mu.Lock()
|
||||
b.canceled = true
|
||||
b.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.calls++
|
||||
b.trace.add("tool:done")
|
||||
return "slow-done", nil
|
||||
}
|
||||
|
||||
// counts 返回该工具被完整执行的次数与被取消的次数。
|
||||
func (b *boundaryTool) counts() (calls int, canceled bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.calls, b.canceled
|
||||
}
|
||||
|
||||
// steerTestCall 是一次「一步工具 + 一步收尾」的脚本:第 1 步请求工具调用,第 2 步
|
||||
// 给出最终答复。它是 I1/I3 的公共前置(一个回合至少要有两条模型步,才存在边界)。
|
||||
const steerTestToolCall = `{"tool_calls":[{"name":"slow","args":{}}]}`
|
||||
|
||||
// steerTestAdapter 返回一个两步脚本的适配器与它的工具。
|
||||
func steerTestAdapter(tool *boundaryTool) *mockAdapter {
|
||||
return &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: steerTestToolCall}},
|
||||
{resp: model.Response{Content: "两步收尾"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestRun_SteerInjectedAtStepBoundary_SingleTurn 钉住 I1 与 §2.4 的最新性:忙时新消息
|
||||
// 在**下一个 step 边界**被吃进同一回合 —— 第 2 步的模型窗口里有它(且排在工具结果
|
||||
// 之后),而整轮只有 2 次模型调用、只有 1 个回合结果。
|
||||
func TestRun_SteerInjectedAtStepBoundary_SingleTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := &boundaryTool{}
|
||||
ad := steerTestAdapter(tool)
|
||||
td := dispatcherWith(t, tool)
|
||||
l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8})
|
||||
|
||||
src := newSteerScript(map[int][]model.Message{
|
||||
2: {model.TextMessage(model.RoleUser, "忙时新消息")},
|
||||
})
|
||||
res, err := l.Run(WithSteerSource(WithSessionEnv(context.Background(), envOwner()), src), "sess-steer", []model.Message{model.TextMessage(model.RoleUser, "第一条")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// I1:只有一个回合(Steps=2 的两步同一回合),不是「注入后又开了一回合」。
|
||||
if res.Steps != 2 || res.StopReason != agent.StopReasonStop {
|
||||
t.Fatalf("result = %+v, want a single 2-step turn ending with stop", res)
|
||||
}
|
||||
if len(ad.reqs) != 2 {
|
||||
t.Fatalf("模型调用 %d 次, want 2(一次工具步 + 一次收尾步;注入绝不新开回合)", len(ad.reqs))
|
||||
}
|
||||
// 边界:每个 step 组装前问一次,正好 [1 2]。
|
||||
if got := src.askedSteps(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("转向边界 = %v, want [1 2]", got)
|
||||
}
|
||||
|
||||
step2 := ad.reqs[1].Messages
|
||||
last := step2[len(step2)-1]
|
||||
if last.Role != model.RoleUser || messageText(last) != "忙时新消息" {
|
||||
t.Fatalf("第 2 步窗口末条 = %+v, want 注入的忙碌消息(它必须对同一回合的下一个模型步立即可见)", last)
|
||||
}
|
||||
// 旧窗口已产出的内容(工具结果)完好:注入是追加,不是替换。
|
||||
if !windowContainsRoleText(step2, model.RoleTool, "slow-done") {
|
||||
t.Fatalf("第 2 步窗口丢了工具结果(注入不得顶掉旧窗口内容):%+v", windowTexts(step2))
|
||||
}
|
||||
// 第一条用户消息仍在窗口里(同一窗口,不是新回合)。
|
||||
if !windowContainsRoleText(step2, model.RoleUser, "第一条") {
|
||||
t.Fatalf("第 2 步窗口丢了本轮第一条用户消息:%+v", windowTexts(step2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SteerDoesNotCancelInFlightTools 钉住 I3:注入发生在**一批工具调用结束之后**
|
||||
// —— 工具完整跑完(未被取消),其结果在窗口里,且注入**在事件序上晚于**工具的完成
|
||||
// (Windows 时间戳粒度粗到两者同值,故用共享事件序而不是时间比较)。
|
||||
func TestRun_SteerDoesNotCancelInFlightTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &steerTrace{}
|
||||
tool := &boundaryTool{delay: 20 * time.Millisecond, trace: tr}
|
||||
ad := steerTestAdapter(tool)
|
||||
td := dispatcherWith(t, tool)
|
||||
l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8})
|
||||
|
||||
src := newSteerScript(map[int][]model.Message{
|
||||
2: {model.TextMessage(model.RoleUser, "工具跑着的时候我插一句")},
|
||||
}).withTrace(tr)
|
||||
res, err := l.Run(WithSteerSource(context.Background(), src), "sess-tools", []model.Message{model.TextMessage(model.RoleUser, "去跑那个慢工具")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
calls, canceled := tool.counts()
|
||||
if calls != 1 || canceled {
|
||||
t.Fatalf("工具 calls=%d canceled=%v, want 1/false(转向绝不取消已发起的工具)", calls, canceled)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||||
}
|
||||
// 事件序必须恰好是「工具跑完」→「边界注入」:注入点落在工具批次**之后**。
|
||||
if got := tr.snapshot(); len(got) != 2 || got[0] != "tool:done" || got[1] != "steer:工具跑着的时候我插一句" {
|
||||
t.Fatalf("事件序 = %v, want [tool:done steer:…](注入必须在工具批次结束之后的边界上)", got)
|
||||
}
|
||||
// 注入只能发生在边界上:整个回合的边界恰好是 [1 2],没有任何「批次中途」的调用。
|
||||
if got := src.askedSteps(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("转向边界 = %v, want [1 2](批次中途绝不注入)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SteerDoesNotPolluteCheckpointOrExtraction 钉住 I2 的另一半:注入只进本轮
|
||||
// 视图,**不进**该回合的运行窗口 —— checkpoint 快照与后台记忆提取的输入面里都不该
|
||||
// 出现它,而该回合真正的输入与工具结果必须在。
|
||||
func TestRun_SteerDoesNotPolluteCheckpointOrExtraction(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := &boundaryTool{}
|
||||
ad := steerTestAdapter(tool)
|
||||
td := dispatcherWith(t, tool)
|
||||
cp := newTestCheckpointStore(t)
|
||||
extractor := &knowledgeCaptureExtractor{}
|
||||
l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8, Checkpoint: cp, Memory: newTestMemoryStore(t), Extractor: extractor})
|
||||
|
||||
const busy = "忙时插进来的一句"
|
||||
src := newSteerScript(map[int][]model.Message{2: {model.TextMessage(model.RoleUser, busy)}})
|
||||
if _, err := l.Run(WithSteerSource(context.Background(), src), "sess-cp", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// 模型确实看到了它(否则这条断言就没有意义)。
|
||||
if !windowContainsRoleText(ad.reqs[len(ad.reqs)-1].Messages, model.RoleUser, busy) {
|
||||
t.Fatal("测试前提不成立:注入的消息没有出现在模型窗口里")
|
||||
}
|
||||
|
||||
latest, err := cp.LoadLatest(context.Background(), "sess-cp")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLatest: %v", err)
|
||||
}
|
||||
var st checkpointState
|
||||
if err := json.Unmarshal(latest.State, &st); err != nil {
|
||||
t.Fatalf("unmarshal checkpoint: %v", err)
|
||||
}
|
||||
if msgInAny(st.Messages, busy) {
|
||||
t.Fatalf("注入的消息进了 checkpoint 快照(只应影响本轮视图):%+v", windowTexts(st.Messages))
|
||||
}
|
||||
if !windowContainsRoleText(st.Messages, model.RoleTool, "slow-done") {
|
||||
t.Fatalf("快照里丢了工具结果:%+v", windowTexts(st.Messages))
|
||||
}
|
||||
if len(extractor.saw) == 0 {
|
||||
t.Fatal("测试前提不成立:提取器没有被调用")
|
||||
}
|
||||
if msgInAny(extractor.saw, busy) {
|
||||
t.Fatalf("注入的消息进了记忆提取面(会被写成记忆):%+v", windowTexts(extractor.saw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SteerRestrictedSessionStaysRestricted 是红线回归:群聊/陌生人会话**不因
|
||||
// 转向注入**而拿到主人身份知识(design/11 §0)—— 注入的只是那条用户消息,人格知识
|
||||
// 块仍按会话环境门控(主人会话有、受限会话一个字都没有),且注入不改变本轮 env。
|
||||
func TestRun_SteerRestrictedSessionStaysRestricted(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env SessionEnv
|
||||
wantKnow bool
|
||||
}{
|
||||
{name: "主人会话", env: envOwner(), wantKnow: true},
|
||||
{name: "群聊", env: envGroup(), wantKnow: false},
|
||||
{name: "陌生人私聊", env: envStranger(), wantKnow: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := &boundaryTool{}
|
||||
ad := steerTestAdapter(tool)
|
||||
td := dispatcherWith(t, tool)
|
||||
l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8, PersonaKnowledgeFor: gateKnowledge})
|
||||
|
||||
src := newSteerScript(map[int][]model.Message{2: {model.TextMessage(model.RoleUser, "群里插一句")}})
|
||||
ctx := WithSteerSource(WithSessionEnv(context.Background(), tc.env), src)
|
||||
if _, err := l.Run(ctx, "sess-env", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
step2 := ad.reqs[1].Messages
|
||||
if !windowContainsRoleText(step2, model.RoleUser, "群里插一句") {
|
||||
t.Fatal("测试前提不成立:注入的消息没有出现在模型窗口里")
|
||||
}
|
||||
if got := knowledgeIn(step2); got != tc.wantKnow {
|
||||
t.Fatalf("身份知识块在窗口里 = %v, want %v(注入不得绕过会话门)", got, tc.wantKnow)
|
||||
}
|
||||
if env := SessionEnvFrom(ctx); env != tc.env {
|
||||
t.Fatalf("本轮 env 被注入改变了:%+v, want %+v", env, tc.env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SteerAbsent_ViewUnchanged 钉住向后兼容:没有转向源的 Run,模型窗口与接线
|
||||
// 之前逐条一致(前导系统块 + 对话 + 工具结果),绝无多余消息。
|
||||
func TestRun_SteerAbsent_ViewUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := &boundaryTool{}
|
||||
ad := steerTestAdapter(tool)
|
||||
td := dispatcherWith(t, tool)
|
||||
l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8})
|
||||
|
||||
if _, err := l.Run(context.Background(), "sess-plain", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
step2 := ad.reqs[1].Messages
|
||||
if len(step2) != 3 {
|
||||
t.Fatalf("未接线转向源时第 2 步窗口 = %d 条 (%v), want 3(原用户消息 + 助手工具调用轮 + 工具结果)",
|
||||
len(step2), windowTexts(step2))
|
||||
}
|
||||
if msgInAny(step2, "忙时新消息") {
|
||||
t.Fatalf("未接线却出现了注入内容:%+v", windowTexts(step2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_MarkInputSteered 钉住 design/15 §5 的 flow 标记:注入时给 input
|
||||
// 节点打 steered:true,且**不新建节点**(图仍是一条主干,与 I1 一致)。
|
||||
func TestFlowPublisher_MarkInputSteered(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var published []any
|
||||
p := NewFlowPublisher(func(_ string, payload any) {
|
||||
mu.Lock()
|
||||
published = append(published, payload)
|
||||
mu.Unlock()
|
||||
}, nil)
|
||||
p.StartRound("web", "第一条")
|
||||
p.MarkInputSteered("web")
|
||||
|
||||
mu.Lock()
|
||||
last := published[len(published)-1]
|
||||
mu.Unlock()
|
||||
graph, ok := last.(*trace.FlowGraph)
|
||||
if !ok {
|
||||
t.Fatalf("published payload = %T", last)
|
||||
}
|
||||
if len(graph.Nodes) != 1 {
|
||||
t.Fatalf("节点数 = %d, want 1(标记只并入既有 input 节点)", len(graph.Nodes))
|
||||
}
|
||||
got := graph.Nodes[0]
|
||||
if got.ID != "input" || got.Props["steered"] != true || got.Label != "第一条" {
|
||||
t.Fatalf("input 节点 = %+v, want 保留标签并带上 steered:true", got)
|
||||
}
|
||||
}
|
||||
|
||||
// windowTexts 把一份模型窗口渲染为 role:文本 列表,供失败信息定位。
|
||||
func windowTexts(msgs []model.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, string(m.Role)+":"+messageText(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowContainsRoleText 报告窗口里是否存在该角色、且文本包含 want 的消息。
|
||||
func windowContainsRoleText(msgs []model.Message, role model.Role, want string) bool {
|
||||
for _, m := range msgs {
|
||||
if m.Role == role && strings.Contains(messageText(m), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// msgInAny 报告任何一条消息的文本里是否含 probe。
|
||||
func msgInAny(msgs []model.Message, probe string) bool {
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(messageText(m), probe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user