- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配) - StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧) - 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变) - 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义 - I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级 - session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变) - 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID - 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生) - 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason) - 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
81 lines
4.2 KiB
Go
81 lines
4.2 KiB
Go
package tool
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
)
|
||
|
||
// 本文件是**接口预留**(design/15 §2.5.7 与主人 2026-10-05 裁决 3),本切片
|
||
// **不实现也不接线**:忙时并行(design/15 §2.5)落地后,同一会话可以有多条在途
|
||
// 回合,而写类工具(执行命令 / 修改文件 / 对外发消息 / 改配置,即 design/11 §4
|
||
// 的敏感清单)在两路之间会真正争抢同一个副作用目标。
|
||
//
|
||
// 主人口径(裁决 3)是**文件级**互斥,而不是会话级串行:
|
||
// - 工具先检测目标文件此刻是否正被**别的在途路**操作;
|
||
// - **同一文件**正在被操作 ⇒ 把「该文件正在被操作」**显式**作为工具结果返回给
|
||
// LLM,并提示稍后再试(**不静默、不排队死等**);
|
||
// - **不是同一个文件** ⇒ 允许并行操作。
|
||
//
|
||
// 为什么只留接口:写类工具的「目标文件」需要逐工具声明(命令、补丁、配置写入
|
||
// 各自的目标语义不同),且占用登记要挂在**路**的生命周期上(路一收尾就必须释放,
|
||
// 否则一个中途报错的路会把那个文件永久锁住)。这两件都属于后续切片,本切片只把
|
||
// 通道与判定口径钉死,使实现方不必再改工具接口。
|
||
//
|
||
// 身份与权限**不走这条通道**:它只回答「此刻有没有另一路正在动这个文件」,与
|
||
// actor 元数据 / 策略引擎无关(项目红线)。
|
||
|
||
// ErrFileBusy 报告目标文件此刻正被另一条在途路操作。它不是执行失败:调用方
|
||
// (写类工具)应把它**显式**返回给 LLM,让模型稍后重试,而不是静默吞掉或排队
|
||
// 死等(主人裁决 3)。错误文案面向模型,因此写清「同一文件被另一路占用」与
|
||
// 「请稍后再试」,绝不泄露另一路的内容。
|
||
var ErrFileBusy = errors.New("tool: 目标文件正在被另一条并行回合操作,请稍后再试")
|
||
|
||
// FileBusyError 把 ErrFileBusy 与具体目标绑成一条可直接交给 LLM 的错误,
|
||
// 同时保持 errors.Is(err, ErrFileBusy) 成立(%w 包装)。target 是**服务端
|
||
// 规范化后**的文件标识,调用方不得把用户原始输入当作它(那会把路径穿越或
|
||
// 隐私内容带进模型上下文)。
|
||
func FileBusyError(target string) error {
|
||
return fmt.Errorf("%w(目标:%s)", ErrFileBusy, target)
|
||
}
|
||
|
||
// FileClaimer 是按**文件级**粒度登记在途操作的接口(主人裁决 3)。实现方
|
||
// (后续切片)负责:
|
||
// - 以规范化后的目标为键,记录「哪一条路正在操作它」;
|
||
// - Claim 在目标已被**另一条路**占用时返回 ErrFileBusy(同一路重复 Claim
|
||
// 同一目标必须是幂等的,否则一条路自己的多步工具调用会互相挡);
|
||
// - Release 无条件释放该路在该目标上的占用(路收尾时的兜底),legID 不是
|
||
// 占用者时是空操作。
|
||
//
|
||
// 实现必须是**有界的**:占用表随路收尾清空,绝不随会话长度增长。
|
||
type FileClaimer interface {
|
||
Claim(ctx context.Context, legID, target string) error
|
||
Release(ctx context.Context, legID, target string)
|
||
}
|
||
|
||
// legIDKey 是「本回合属于哪条并发路」的 context key。
|
||
type legIDKey struct{}
|
||
|
||
// WithLegID 把本回合所属的并发路标识挂到 ctx 上。循环在 Run 顶部按 ctx 上挂的
|
||
// 路标识播种一次(单路 / 子代理 / 后台思考为空串 ⇒ 不挂载),工具经
|
||
// Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」——这是文件级占用
|
||
// 登记的唯一来源,绝不从模型输出推断。
|
||
//
|
||
// 空 legID 是空操作:单路时未挂载,读取侧返回空串,与加入本特性之前逐字相同。
|
||
func WithLegID(ctx context.Context, legID string) context.Context {
|
||
if ctx == nil || legID == "" {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, legIDKey{}, legID)
|
||
}
|
||
|
||
// LegIDFrom 返回 ctx 上挂的并发路标识;未挂载(单路、子代理、未接线)时返回
|
||
// 空串。空串表示「本回合不与其他路并行」,此时文件级互斥**无需生效**。
|
||
func LegIDFrom(ctx context.Context) string {
|
||
if ctx == nil {
|
||
return ""
|
||
}
|
||
s, _ := ctx.Value(legIDKey{}).(string)
|
||
return s
|
||
}
|