feat(loop): 落地 design/15 §2.5.6 并行提示(结构化 ParallelNotice + 独立 system 行,杜绝一题多答/跨端串话)

This commit is contained in:
AskaEth committed 2026-10-05 12:12:23 +08:00
1 parent a79fe9684e
commit fa850f9599
3 files changed
+423

No files matched your search

+134
View File
@@ -2,6 +2,8 @@ package loop
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -101,3 +103,135 @@ func (l *Loop) steerAtBoundary(ctx context.Context, sessionID string, step int,
l.log.Info("steer injected", "session", sessionID, "step", step, "messages", len(msgs))
return append(steered, msgs...)
}
// ---------------------------------------------------------------------------
// 并行路进行态摘要(design/15 §2.5.6,主人 2026-10-05 裁决 B 的落地)
// ---------------------------------------------------------------------------
//
// 背景(真实事故):主人连发三条消息(两条来自安卓语音助手设备 `voiceassist:cyrene:v1`、
// 一条微信图片),后端真并发跑了三路;三路因为**互相看不见**,各自把同一句
// 「写 2000 字关于海洋」当成自己的题作答(三篇作文),其中一路还把别路的作文答进了
// 另一个客户端(跨端串话)。§2.5.6 的「进行态摘要」就是为了让每一路**结构化地**
// 知道「本会话此刻另有一路在跑」,从而各答各的。
//
// 它只含**元数据**(对方消息 id / 步数 / 已调用工具名 / 排队条数),结构里没有任何承载
// 对方正文的字段 —— 半句话会诱导本路去「补全」或「抢答」,也无法诚实地标注
// who said what(§2.5.6「不做的事」①)。它作为**独立的 system 消息**注入本轮视图,
// **绝不拼进任何 user 正文**(项目红线:用户内容只进 user 角色消息);这段文本也
// **不**承载任何身份 / 权限信息(身份识别靠 channel 层 actor 元数据 + 策略引擎)。
//
// 注入点与口径与 steer 注入**完全相同**(§2.5.6 明文要求):在 driveTurn 每步的
// assembleContext 之后追加进**本轮视图**,不写回 msgs ⇒ 不进 checkpoint 快照、
// 不进后台记忆提取的输入面。
// design/15 §2.5.6 定死的措辞(逐字,含标点)。头尾两句在任何字段为空时也照发 ——
// 「另有一路在跑、你独立作答」本身就是这条提示要传递的核心事实,元数据只是增益。
const (
parallelNoticeHeader = "[并行路提示] 该会话当前另有一路正在进行中(不打断它)。"
parallelNoticeTail = "你这一路独立作答;不要复述或改写对方可能给出的内容。"
)
// ParallelNotice 是本会话「另有一路正在进行」的**结构化**进行态摘要(design/15 §2.5.6)。
//
// 它由**网关**(会话编排器)在起第二路时按该路上游的实时状态构造,经
// WithParallelNotice 挂到该路的 runCtx 上;循环在每一步把它渲染成一条独立的
// system 消息(appendParallelNotice)。**空字段对应的那一段不渲染**,因此哪怕一条
// 元数据都没有,挂载这个值本身仍然是有意义的信号(头尾两句照发)。
//
// 结构里**没有**任何能承载对方半成品正文的字段 —— 这条红线是**结构性**的:
// 想拼也拼不进来。
type ParallelNotice struct {
// OtherMessageID 是另一路(或上一条)用户消息的服务端 id,作为「上一条消息 id」
// 渲染。为空时该段不渲染。
OtherMessageID string
// Step 是另一路**已进行到**的步序号(1 基)。<= 0 时该段不渲染。
Step int
// ToolNames 是另一路已经调用过的工具名,按调用顺序、已去重。为空时该段不渲染。
ToolNames []string
// Queued 是该会话仍在 pending、尚未被任何一路接手的消息条数。<= 0 时该段不渲染。
Queued int
}
// parallelNoticeKey 是并行路进行态摘要的 context key。
type parallelNoticeKey struct{}
// WithParallelNotice 把进行态摘要挂到 ctx 上,使本次 Run 每一步的模型可见视图都带上
// 一条独立的 system 提示(design/15 §2.5.6)。调用方(网关会话编排器)在**起第二路
// 之前**挂一份:摘要是该路起点的一份**快照**,中途不再改写(I4:两路之间不做运行中
// 的实时可见)。nil ctx 是空操作,ctx 原样返回 —— 未挂该键的运行(子代理 / 后台思考 /
// 未接网关的循环 / 单路)**行为逐字不变**。
func WithParallelNotice(ctx context.Context, notice ParallelNotice) context.Context {
if ctx == nil {
return ctx
}
return context.WithValue(ctx, parallelNoticeKey{}, notice)
}
// ParallelNoticeFrom 返回 ctx 上挂的进行态摘要,ok 报告是否挂载。它是
// WithParallelNotice 的读侧,供注入点与测试观察本路的提示能力。
//
// 它刻意返回 ok 而不只返回值:零值摘要也是「另有一路在跑」的有效信号(头尾两句本身
// 就是核心事实),因此「未挂载」不能与「挂了但没元数据」混为一谈。
func ParallelNoticeFrom(ctx context.Context) (ParallelNotice, bool) {
if ctx == nil {
return ParallelNotice{}, false
}
notice, ok := ctx.Value(parallelNoticeKey{}).(ParallelNotice)
return notice, ok
}
// parallelNoticeText 把进行态摘要渲染成 design/15 §2.5.6 定死的那段文本。措辞(含
// 标点与全角分隔)逐字取自设计;只有**有数据的字段**才出现在事实行里,使空字段不
// 产生空段(如「已进行到第 0 步」这种无信息量的噪声)。
func parallelNoticeText(notice ParallelNotice) string {
var b strings.Builder
b.WriteString(parallelNoticeHeader)
if facts := parallelNoticeFacts(notice); facts != "" {
b.WriteString("\n")
b.WriteString(facts)
}
if notice.Queued > 0 {
fmt.Fprintf(&b, "\n另有 %d 条消息在排队(未开始)。", notice.Queued)
}
b.WriteString("\n")
b.WriteString(parallelNoticeTail)
return b.String()
}
// parallelNoticeFacts 渲染事实行(设计示例里的
// 「上一条消息 id:msg-…;已进行到第 3 步;已调用工具:search_documents、document-read。」):
// 只列出有值的元数据段,以全角「;」分隔、以「。」收尾。一段都没有时返回空串,
// 该行整体不出现。
func parallelNoticeFacts(notice ParallelNotice) string {
parts := make([]string, 0, 3)
if notice.OtherMessageID != "" {
parts = append(parts, "上一条消息 id:"+notice.OtherMessageID)
}
if notice.Step > 0 {
parts = append(parts, fmt.Sprintf("已进行到第 %d 步", notice.Step))
}
if len(notice.ToolNames) > 0 {
parts = append(parts, "已调用工具:"+strings.Join(notice.ToolNames, "、"))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ";") + "。"
}
// appendParallelNotice 在**本步视图**末尾追加 §2.5.6 的进行态摘要,作为一条
// **独立的 system 消息**。它必须在 assembleContext **之后**调用(§2.5.6 的落点)。
//
// 它只改本轮视图、**不写回 msgs**:checkpoint 快照与后台记忆提取的输入面因此完全
// 看不到它(与 steer 注入同一口径,见本文件头)。ctx 上未挂摘要时它原样返回 view,
// 不做任何分配 ⇒ 单路 / 子代理 / 后台思考的路径**零成本、逐字不变**。
func (l *Loop) appendParallelNotice(ctx context.Context, sessionID string, step int, view []model.Message) []model.Message {
notice, ok := ParallelNoticeFrom(ctx)
if !ok {
return view
}
l.log.Debug("parallel notice injected", "session", sessionID, "step", step,
"other_message", notice.OtherMessageID, "other_step", notice.Step,
"tools", len(notice.ToolNames), "queued", notice.Queued)
return append(view, model.TextMessage(model.RoleSystem, parallelNoticeText(notice)))
}