缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自 loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg 会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道: - 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖, 取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖 - checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化 守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。 变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道 (守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。 另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
378 lines
15 KiB
Go
378 lines
15 KiB
Go
package tool
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// 本文件把 legfile.go 预留的文件级互斥通道**实现并接线**(design/15 §2.5.7、
|
|
// 主人 2026-10-05 裁决 3):写类工具执行前先在占用表里登记本次要动的文件,被
|
|
// **另一条在途路**占住时立刻把「该文件正在被另一路操作」以面向模型的文案返回
|
|
// 并提示稍后重试(不静默、不排队死等);不是同一个文件则照常并行。
|
|
//
|
|
// 「本次要动哪个文件」由**工具自己**实现可选的 FileTargeter 接口声明,而不是
|
|
// 由本包从 JSON Schema 标注或从参数正文里猜:同一个工具的文件参数会随调用而变
|
|
// (内置 `file` 工具的 `path` 在 action=read 时是读、action=write 时是写;还有
|
|
// 工具用 `name`、有的收多个路径),只有工具实现方知道本次调用的语义。静态标注
|
|
// 要么表达不了「本次是读还是写」,要么逼工具把语义抄两遍;字符串猜文件名则会
|
|
// 把正文里的任意文本当成路径(误挡 + 泄漏面)。声明不出来 ⇒ 该工具不参与互斥,
|
|
// 行为与加入本特性之前逐字相同。
|
|
//
|
|
// 占用键 = **操作类别 + 规范化路径**(见 fileClaimKey):同一文件的两个**读**
|
|
// 允许并行(主人「优先考虑性能而不是预算」口径:检索类并行不该被互斥拖住),
|
|
// 读与写、写与写互斥。类别编码进键、而不是给 FileClaimer 多加一对方法,是为了
|
|
// 不破坏 legfile.go 已钉死的两方法接口形状(既有用例零改动)。
|
|
|
|
// FileOp 是一次工具调用对目标文件的操作类别。粒度只到读/写两类:读不改变文件
|
|
// 内容,读与读之间没有数据风险;写会改变内容,与任何路的读写都不能重叠。
|
|
type FileOp string
|
|
|
|
const (
|
|
// FileOpRead 表示**只读**访问目标文件(可与别的读并行)。
|
|
FileOpRead FileOp = "read"
|
|
// FileOpWrite 表示会**修改**目标文件(与其他路的任何读写都互斥)。
|
|
FileOpWrite FileOp = "write"
|
|
)
|
|
|
|
// FileTarget 是工具为**本次调用**声明的一个目标文件。Op 为空/未知一律按
|
|
// FileOpWrite 处理:占用表的方向是宁可多挡一次(让模型稍后重试),
|
|
// 也不放过两路并发写。
|
|
type FileTarget struct {
|
|
// Op 是本次调用的操作类别(读 / 写)。
|
|
Op FileOp
|
|
// Path 是本次调用要碰的路径;以工具**自己执行时使用的那个路径**为准
|
|
// (沙箱解析后的绝对路径最好)。
|
|
Path string
|
|
}
|
|
|
|
// FileTargeter 由需要参与文件级互斥的工具**可选**实现:返回本次调用会碰到的
|
|
// 文件(含读/写类别)。未实现它的工具完全不参与互斥。
|
|
//
|
|
// 约定:
|
|
// - Path 应是工具执行时真正用的路径;占用表只做一次**词法**规范化(清理相对
|
|
// 段、Windows 折叠大小写),它不解析符号链接、不统一短名/UNC/映射盘 ——
|
|
// 别把沙箱解析责任推给占用表。
|
|
// - 只声明真正会碰的文件:声明得越宽并行度越低(例如把每次写都声明成同一个
|
|
// 目录名,就等于退化成全局串行)。
|
|
// - 实现必须是纯函数(无副作用、不阻塞):它在每次工具调用前被调用一次,
|
|
// 而且它 panic 会被吞掉并降级为「不参与互斥」(见 safeFileTargets)。
|
|
type FileTargeter interface {
|
|
FileTargets(args any) []FileTarget
|
|
}
|
|
|
|
// fileClaimKeySep 分隔占用键里的操作类别与路径。用 NUL 是因为它在 Windows 与
|
|
// POSIX 的文件名里都非法,真实路径不可能与它撞键(loop 的 toolFingerprint 用
|
|
// 同一种分隔符表达同样的「不会撞」拼接)。
|
|
const fileClaimKeySep = "\x00"
|
|
|
|
// fileClaimKey 拼出占用键(调用方与占用表共用同一个拼法)。
|
|
func fileClaimKey(op FileOp, path string) string {
|
|
return string(normalizeFileOp(op)) + fileClaimKeySep + path
|
|
}
|
|
|
|
// normalizeFileOp 把空/未知类别收敛为「写」。
|
|
func normalizeFileOp(op FileOp) FileOp {
|
|
if op == FileOpRead {
|
|
return FileOpRead
|
|
}
|
|
return FileOpWrite
|
|
}
|
|
|
|
// parseFileClaimKey 把占用键拆回(类别,路径)。没有类别前缀的裸路径(外部
|
|
// 实现方直接传路径 —— legfile_test 的 fakeFileClaimer 就是这种口径)按「写」
|
|
// 处理,与 normalizeFileOp 同一个保守方向。
|
|
func parseFileClaimKey(target string) (FileOp, string) {
|
|
op, path, ok := strings.Cut(target, fileClaimKeySep)
|
|
if !ok {
|
|
return FileOpWrite, target
|
|
}
|
|
if op == string(FileOpRead) {
|
|
return FileOpRead, path
|
|
}
|
|
return FileOpWrite, path
|
|
}
|
|
|
|
// canonicalFilePath 把路径收敛成占用表的键。只做**词法**规范化(Abs + Clean +
|
|
// Windows 折叠大小写),绝不 touch 文件系统:占用判定在每次工具调用之前发生,
|
|
// 不能引入 I/O,也不该因为目标文件还不存在就放弃互斥。
|
|
//
|
|
// 代价(诚实登记):符号链接、8.3 短名、UNC 与映射盘写法的**同一物理文件**不会
|
|
// 被合并 —— 两个别名会被当成两个文件而放行并行。工具按自己执行时的真实落点
|
|
// 声明路径即可避免绝大多数别名(见 FileTargeter 的约定)。
|
|
func canonicalFilePath(p string) string {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
if abs, err := filepath.Abs(p); err == nil {
|
|
p = abs
|
|
}
|
|
p = filepath.Clean(p)
|
|
if runtime.GOOS == "windows" {
|
|
// Windows 文件系统大小写不敏感:`A.TXT` 与 `a.txt` 是同一个文件,不折叠
|
|
// 就会漏判成两个键,互斥形同虚设。
|
|
p = strings.ToLower(p)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// fileClaims 是 FileClaimer 的进程内实现。单二进制、单进程 ⇒ 占用状态无需跨
|
|
// 进程共享(项目红线:零跨进程 IPC)。
|
|
//
|
|
// 语义是**读共享 / 写独占**(而不是「只登记写、读不登记」):一路在写时另一路
|
|
// 读同一文件会读到撕开的内容(file 工具的读不是原子快照),与两路同时写属同一
|
|
// 类数据风险,所以读也登记;但多个读互不阻挡,检索类并行不受影响。
|
|
type fileClaims struct {
|
|
mu sync.Mutex
|
|
held map[string]*fileHolders
|
|
}
|
|
|
|
// fileHolders 是某个文件当前的占用者集合(键是路标识):写至多一个,读可多个。
|
|
// 两个集合都空时该键会被立刻删除,因此占用表随路收尾清空、绝不随会话长度增长。
|
|
type fileHolders struct {
|
|
writers map[string]struct{}
|
|
readers map[string]struct{}
|
|
}
|
|
|
|
// NewFileClaimer 构建一份进程内文件占用表。跨会话共享同一份即可:粒度的口径是
|
|
// **文件**(主人裁决 3),不是会话 —— 两个会话同时改同一个文件同样是数据风险。
|
|
func NewFileClaimer() FileClaimer {
|
|
return &fileClaims{held: make(map[string]*fileHolders)}
|
|
}
|
|
|
|
// Claim 实现 FileClaimer:按占用键的类别做读共享/写独占判定,被别的路占住时
|
|
// 返回带文件状态的 ErrFileBusy(直接回灌给模型)。它**绝不等待**:判忙即返回,
|
|
// 由模型稍后重试(主人裁决 3 的「不静默、不排队死等」)。
|
|
//
|
|
// 空 legID 或空目标是空操作:子代理 / 后台思考 / 未接编排器的调用方(ctx 上没有
|
|
// 互斥身份)不登记也不检查,行为与加入本特性之前逐字相同(兼容硬要求)。
|
|
//
|
|
// 同一路重复 Claim 同一目标幂等;一路自己先读后写同一文件不会被自己挡住
|
|
// (否则一条路自己的多步工具调用会互相锁死)。
|
|
func (c *fileClaims) Claim(ctx context.Context, legID, target string) error {
|
|
if c == nil || legID == "" || target == "" {
|
|
return nil
|
|
}
|
|
op, path := parseFileClaimKey(target)
|
|
key := canonicalFilePath(path)
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
h := c.held[key]
|
|
if op == FileOpWrite {
|
|
if _, busy := h.otherWriter(legID); busy {
|
|
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpWrite))
|
|
}
|
|
if _, busy := h.otherReader(legID); busy {
|
|
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpRead))
|
|
}
|
|
} else if _, busy := h.otherWriter(legID); busy {
|
|
return FileBusyStatusError(key, fileBusyStatus(FileOpRead, FileOpWrite))
|
|
}
|
|
if h == nil {
|
|
h = newFileHolders()
|
|
c.held[key] = h
|
|
}
|
|
if op == FileOpWrite {
|
|
h.writers[legID] = struct{}{}
|
|
} else {
|
|
h.readers[legID] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Release 实现 FileClaimer:无条件摘掉该路在该目标上的占用(读、写两侧都摘),
|
|
// 最后一个占用者离开时删键。legID 不是占用者时是空操作。
|
|
//
|
|
// 它必须挂在工具执行的生命周期上(defer):报错 / 超时 / panic 的路也绝不把
|
|
// 文件永久锁住。
|
|
func (c *fileClaims) Release(ctx context.Context, legID, target string) {
|
|
if c == nil || legID == "" || target == "" {
|
|
return
|
|
}
|
|
_, path := parseFileClaimKey(target)
|
|
key := canonicalFilePath(path)
|
|
if key == "" {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
h := c.held[key]
|
|
if h == nil {
|
|
return
|
|
}
|
|
delete(h.writers, legID)
|
|
delete(h.readers, legID)
|
|
if len(h.writers) == 0 && len(h.readers) == 0 {
|
|
delete(c.held, key)
|
|
}
|
|
}
|
|
|
|
// fileBusyStatus 生成回灌给模型的**文件状态**短句:说清此刻是谁在动它,
|
|
// 模型才知道该等什么、为什么现在不能动(而不是只看到「忙」)。
|
|
// 只含操作类别,不含另一路的任何身份或内容。
|
|
func fileBusyStatus(want, holder FileOp) string {
|
|
verb := "写入"
|
|
if holder == FileOpRead {
|
|
verb = "读取"
|
|
}
|
|
return fmt.Sprintf("该文件正在被另一路%s,本次%s无法进行", verb, opLabel(want))
|
|
}
|
|
|
|
// opLabel 把操作类别渲染成中文,供面向模型的文案使用。
|
|
func opLabel(op FileOp) string {
|
|
if op == FileOpRead {
|
|
return "读取"
|
|
}
|
|
return "写入"
|
|
}
|
|
|
|
// newFileHolders 建一个空占用者集合(两个集合都必须非 nil,Claim 会直接写入)。
|
|
func newFileHolders() *fileHolders {
|
|
return &fileHolders{writers: make(map[string]struct{}), readers: make(map[string]struct{})}
|
|
}
|
|
|
|
// otherWriter 报告除 exclude 外是否还有路正在写该文件,并返回其中一条(供文案
|
|
// 与调试使用)。nil 接收者表示「该文件当前无人占用」⇒ (空串, false),使 Claim
|
|
// 不必先建表再判定。
|
|
//
|
|
// 判定用**布尔**而不是「返回空串表示没有」:路标识本身可以是空串(外部调用方
|
|
// 的口径),用空串当「没有」的哨兵会把它们误判成无人占用。
|
|
func (h *fileHolders) otherWriter(exclude string) (string, bool) {
|
|
if h == nil {
|
|
return "", false
|
|
}
|
|
for leg := range h.writers {
|
|
if leg != exclude {
|
|
return leg, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// otherReader 报告除 exclude 外是否还有路正在读该文件,并返回其中一条。
|
|
func (h *fileHolders) otherReader(exclude string) (string, bool) {
|
|
if h == nil {
|
|
return "", false
|
|
}
|
|
for leg := range h.readers {
|
|
if leg != exclude {
|
|
return leg, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// fileTargetsOf 取工具为本次调用声明的目标文件。工具未实现 FileTargeter、
|
|
// 声明为空、或某项路径为空时跳过 —— 声明不出来就不参与互斥(行为不变)。
|
|
// 同一次调用里重复的目标只登记一次,避免同一把锁被重复占有/重复释放。
|
|
func fileTargetsOf(t sdk.Tool, args any) []FileTarget {
|
|
targeter, ok := t.(FileTargeter)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
targets := targeter.FileTargets(args)
|
|
if len(targets) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]FileTarget, 0, len(targets))
|
|
seen := make(map[string]struct{}, len(targets))
|
|
for _, tg := range targets {
|
|
if strings.TrimSpace(tg.Path) == "" {
|
|
continue
|
|
}
|
|
key := fileClaimKey(tg.Op, tg.Path)
|
|
if _, dup := seen[key]; dup {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, tg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// safeFileTargets 是 fileTargetsOf 的抗 panic 包装:声明是**可选**扩展点,
|
|
// 一个第三方工具在它里面 panic 不该连带炸掉调用(callTool 只在 Execute 里
|
|
// recover)。降级方向是「不参与互斥」,而不是「调用失败」。
|
|
func safeFileTargets(log *slog.Logger, name string, t sdk.Tool, args any) (out []FileTarget) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
out = nil
|
|
log.Warn("file target declaration panicked; skipping file claims",
|
|
"tool", name, "panic", fmt.Sprint(r))
|
|
}
|
|
}()
|
|
return fileTargetsOf(t, args)
|
|
}
|
|
|
|
// claimFiles 为一次工具调用登记它声明的目标文件,并返回释放函数(调用方必须
|
|
// 在执行后 defer 调用它:成功 / 报错 / 超时 / panic 都要释放,否则一个中途报错
|
|
// 的路会把文件永久锁住)。
|
|
//
|
|
// 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、ctx 上没有互斥身份
|
|
// (子代理 / 后台思考 / 未接编排器,兼容硬要求)、或工具没有声明目标文件。
|
|
//
|
|
// 被另一条路占住时**不等待、不排队**,直接把带文件状态的 ErrFileBusy 交给模型
|
|
// (主人裁决 3)。注意它**不包成 ToolError**:占用判定发生在执行之前,调用根本
|
|
// 没跑,而且 ToolError 的渲染(message + cause)会把同一句话打印两遍 —— 回灌给
|
|
// 模型的结果要一句话说清「谁在被谁占、稍后再试」(loop 的 errorKind 把它归为
|
|
// execution,与执行面失败的分类一致)。
|
|
//
|
|
// 多目标里任意一个失败时,本次调用**已登记的全部目标立刻回滚**,绝不留下半登记
|
|
// 状态。
|
|
func (d *Dispatcher) claimFiles(ctx context.Context, name string, t sdk.Tool, args map[string]any) (func(), error) {
|
|
claimer := d.claimerRef()
|
|
if claimer == nil {
|
|
return nil, nil
|
|
}
|
|
legID := LegIDFrom(ctx)
|
|
if legID == "" {
|
|
return nil, nil
|
|
}
|
|
targets := safeFileTargets(d.log, name, t, args)
|
|
if len(targets) == 0 {
|
|
return nil, nil
|
|
}
|
|
keys := make([]string, 0, len(targets))
|
|
for _, tg := range targets {
|
|
key := fileClaimKey(tg.Op, tg.Path)
|
|
// 先记账再登记:失败的 Claim 也要进回滚清单(对第三方实现而言,
|
|
// 「失败即未登记」不是接口保证;多释放一次按契约是空操作)。
|
|
keys = append(keys, key)
|
|
if err := claimer.Claim(ctx, legID, key); err != nil {
|
|
releaseFileClaims(ctx, claimer, legID, keys)
|
|
d.log.Info("file claim busy; asking the model to retry later",
|
|
"tool", name, "leg", legID, "target", key)
|
|
return nil, err
|
|
}
|
|
}
|
|
return func() { releaseFileClaims(ctx, claimer, legID, keys) }, nil
|
|
}
|
|
|
|
// releaseFileClaims 释放一批占用(正常收尾与失败回滚共用一条路径)。
|
|
func releaseFileClaims(ctx context.Context, claimer FileClaimer, legID string, keys []string) {
|
|
for _, key := range keys {
|
|
claimer.Release(ctx, legID, key)
|
|
}
|
|
}
|
|
|
|
// claimerRef 返回已装配的占用表;未装配时返回 nil(⇒ 完全不做互斥)。
|
|
// 占用表只在 New 的选项里装配一次,此后不再变更。
|
|
func (d *Dispatcher) claimerRef() FileClaimer {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.claimer
|
|
}
|