新增 tool/fileclaims.go:FileTargeter 可选接口,由工具声明本次调用要碰的文件 (动态声明、不猜正文 —— 同一工具的 path 在读/写语义不同)。 语义:读共享 / 写独占;两个读允许并行(主人「优先性能」口径);未知操作/裸路径按写处理(保守)。 占用键含操作类别(read/write,NUL 分隔),路径 Abs+Clean+Windows 大小写折叠;随最后占用者离场删键(有界)。 Dispatcher 默认装配进程内占用表(无需 cmd 侧接线);Invoke 登记 + defer 释放(成功/报错/超时/panic 四条路径)。 loop/tools.go 唯一接线:tool.WithLegID(ParallelLegFrom(ctx)) 桥接网关路标识。 判忙不阻塞、不排队死等;显式文案回灌(含文件名与状态)让 LLM 稍后重试。 测试 3 个新文件(占用表语义 / 接线确定性并发 / loop 桥与端到端);变异 5 条,另揪出并修一个 真隐患(空串既当「无人占用」哨兵又是合法路标识 ⇒ 改 (leg,busy) 返回)。 诚实边界:①「1 普通回合 + 1 并行路」时普通回合不受保护(网关只给 parallel 路盖 legID; 修法须与 checkpoint 归属一起裁决);② 目前无真实工具实现 FileTargeter ⇒ 机制就绪但占用表恒空, 真实保护待下一片给 file 等工具加声明。
411 lines
16 KiB
Go
411 lines
16 KiB
Go
// Package tool implements singleshot tool dispatch for the agent runtime
|
||
// (DevDocs/design/04-运行时.md §4). A Dispatcher wraps the global capability
|
||
// registry, resolves a named Tool, validates the caller-supplied arguments
|
||
// against the tool's JSON Schema, and executes it under a per-tool timeout,
|
||
// turning every failure into a ToolError that is safe to feed back to the LLM.
|
||
//
|
||
// This package deliberately covers only a single invocation: the loop upper
|
||
// bound (8 rounds) and retry belong to the agent loop (internal/runtime/loop),
|
||
// not to dispatch.
|
||
package tool
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// DefaultTimeout is the per-invoke deadline used when a tool has no explicit
|
||
// override and the Dispatcher was built without WithDefaultTimeout.
|
||
const DefaultTimeout = 30 * time.Second
|
||
|
||
// Execution modes a tool may declare to the loop. A tool that mutates shared
|
||
// state (file writes, device control) should be marked sequential so the loop
|
||
// does not run it concurrently with other calls.
|
||
const (
|
||
// ExecutionModeParallel is the default: the loop may run such a tool
|
||
// concurrently with other calls in a batch.
|
||
ExecutionModeParallel = "parallel"
|
||
// ExecutionModeSequential forces the loop to run the whole batch serially so
|
||
// this tool is never invoked concurrently.
|
||
ExecutionModeSequential = "sequential"
|
||
)
|
||
|
||
// Error-kind labels distinguishing why a tool call failed, fed back to the model
|
||
// as a structured {kind,message} so it can react differently to a bad argument
|
||
// (re-issue a corrected call) versus an execution failure.
|
||
const (
|
||
// KindArgumentValidation marks a JSON-Schema argument-validation failure.
|
||
KindArgumentValidation = "argument-validation"
|
||
// KindExecution marks a runtime execution failure (timeout, panic, tool error).
|
||
KindExecution = "execution"
|
||
)
|
||
|
||
// Sentinel errors returned by the Dispatcher. Use errors.Is to test for them;
|
||
// they are wrapped with %w so the underlying cause stays available.
|
||
var (
|
||
// ErrUnknownTool is returned when the requested name is not a registered
|
||
// tool, or resolves to a capability of a non-Tool kind.
|
||
ErrUnknownTool = errors.New("tool: unknown tool")
|
||
// ErrInvalidArgs is returned when the caller-supplied arguments fail the
|
||
// tool's JSON Schema validation (missing required, wrong type).
|
||
ErrInvalidArgs = errors.New("tool: invalid arguments")
|
||
// ErrTimeout is returned when a tool does not complete before its deadline.
|
||
ErrTimeout = errors.New("tool: execution timeout")
|
||
// ErrNilRegistry is returned by New when the supplied registry is nil.
|
||
ErrNilRegistry = errors.New("tool: nil registry")
|
||
)
|
||
|
||
// ToolError is the error the Dispatcher returns to the caller on any failed
|
||
// invocation. It carries the tool Name and a human/LLM-readable Message and
|
||
// wraps the underlying cause so sentinel classification survives errors.Is and
|
||
// errors.As.
|
||
type ToolError struct {
|
||
// Name is the tool that failed.
|
||
Name string
|
||
// Message is a safe, human- and LLM-readable description of the failure.
|
||
Message string
|
||
// cause is the wrapped underlying error (may be nil).
|
||
cause error
|
||
}
|
||
|
||
// Error implements the error interface.
|
||
func (e *ToolError) Error() string {
|
||
if e.cause == nil {
|
||
return fmt.Sprintf("tool %q failed: %s", e.Name, e.Message)
|
||
}
|
||
return fmt.Sprintf("tool %q failed: %s: %v", e.Name, e.Message, e.cause)
|
||
}
|
||
|
||
// Unwrap exposes the wrapped cause so errors.Is/errors.As traverse the chain.
|
||
func (e *ToolError) Unwrap() error { return e.cause }
|
||
|
||
// Kind classifies the failure as "argument-validation" (the caller-supplied
|
||
// arguments failed the JSON Schema) or "execution" (anything after validation:
|
||
// unknown tool, timeout, panic or an Execute error). The loop uses it to feed a
|
||
// structured {kind,message} tool result back to the model.
|
||
func (e *ToolError) Kind() string {
|
||
if e.cause != nil && errors.Is(e.cause, ErrInvalidArgs) {
|
||
return KindArgumentValidation
|
||
}
|
||
return KindExecution
|
||
}
|
||
|
||
// Option configures a Dispatcher at construction time.
|
||
type Option func(*Dispatcher)
|
||
|
||
// Dispatcher resolves, validates and executes tools registered in a capability
|
||
// registry. It is safe for concurrent use.
|
||
type Dispatcher struct {
|
||
reg *registry.Registry
|
||
dft time.Duration
|
||
log *slog.Logger
|
||
mu sync.RWMutex // guards timeouts, modes and the file claimer
|
||
timeouts map[string]time.Duration
|
||
modes map[string]string // tool name -> ExecutionMode* (default parallel)
|
||
claimer FileClaimer // 文件级互斥的占用表(见 fileclaims.go)
|
||
sinkMu sync.RWMutex // guards the soft-judge sink
|
||
sink SoftJudgeSink
|
||
}
|
||
|
||
// New builds a Dispatcher backed by reg, applying opts in order. It returns an
|
||
// error wrapping ErrNilRegistry when reg is nil. The default per-invoke timeout
|
||
// is DefaultTimeout unless overridden with WithDefaultTimeout.
|
||
//
|
||
// 默认装配一份进程内文件占用表(fileclaims.go):它对**未声明目标文件**的工具、
|
||
// 以及**未挂路标识**(单路 / 子代理 / 未接线)的调用完全无副作用,因此既有行为
|
||
// 逐字不变;一旦工具实现 FileTargeter 且调用带路标识,写类工具的文件级互斥立即
|
||
// 生效(无需上层再接线)。
|
||
func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
|
||
if reg == nil {
|
||
return nil, fmt.Errorf("tool: new: %w", ErrNilRegistry)
|
||
}
|
||
d := &Dispatcher{
|
||
reg: reg,
|
||
dft: DefaultTimeout,
|
||
log: sourcedLog(nil),
|
||
timeouts: make(map[string]time.Duration),
|
||
modes: make(map[string]string),
|
||
claimer: NewFileClaimer(),
|
||
}
|
||
for _, opt := range opts {
|
||
if opt != nil {
|
||
opt(d)
|
||
}
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
// WithDefaultTimeout sets the default per-invoke deadline for tools without an
|
||
// explicit override. A non-positive value disables the deadline.
|
||
func WithDefaultTimeout(timeout time.Duration) Option {
|
||
return func(d *Dispatcher) { d.dft = timeout }
|
||
}
|
||
|
||
// WithLogger sets the dispatcher's base logger (used by the allow-set soft
|
||
// judge, design/14 §4). Nil restores the discard logger. The stored logger is
|
||
// tagged with the module source (project rule §10).
|
||
func WithLogger(l *slog.Logger) Option {
|
||
return func(d *Dispatcher) { d.log = sourcedLog(l) }
|
||
}
|
||
|
||
// WithFileClaimer 替换文件级互斥使用的占用表(设计/实现见 fileclaims.go)。
|
||
// 传 nil 表示**关闭**文件级互斥(工具声明的目标一律不登记、不检查)—— 供测试
|
||
// 与需要「完全不做互斥」的装配点使用;新构建的 Dispatcher 默认已带一份进程内
|
||
// 占用表,通常无需调用它。
|
||
func WithFileClaimer(c FileClaimer) Option {
|
||
return func(d *Dispatcher) { d.claimer = c }
|
||
}
|
||
|
||
// sourcedLog tags l with the module source "runtime/tool"; nil becomes a
|
||
// discard logger, so the soft judge can always log safely.
|
||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||
if l == nil {
|
||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
return logger.WithSource(l, "runtime/tool")
|
||
}
|
||
|
||
// Register registers t as a tool with the underlying capability registry,
|
||
// delegating duplicate/invalid handling to the registry. A nil tool is rejected
|
||
// by the registry's own validation.
|
||
func (d *Dispatcher) Register(t sdk.Tool) error {
|
||
if d.reg == nil {
|
||
return fmt.Errorf("tool: register: %w", ErrNilRegistry)
|
||
}
|
||
return d.reg.RegisterTool(t)
|
||
}
|
||
|
||
// SetToolTimeout overrides the per-invoke deadline for the named tool. A
|
||
// non-positive value disables the deadline for that tool. The override applies
|
||
// regardless of whether the tool is registered yet.
|
||
func (d *Dispatcher) SetToolTimeout(name string, timeout time.Duration) error {
|
||
if name == "" {
|
||
return fmt.Errorf("tool: set timeout: %w", ErrUnknownTool)
|
||
}
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
d.timeouts[name] = timeout
|
||
return nil
|
||
}
|
||
|
||
// SetToolExecutionMode marks a tool as running "sequential" or "parallel" (the
|
||
// default). A sequential tool is never invoked concurrently by the loop, so
|
||
// shared-state tools (file writes, device control) can declare it. An empty mode
|
||
// clears the override back to the default. Only ExecutionModeSequential is
|
||
// treated as sequential.
|
||
func (d *Dispatcher) SetToolExecutionMode(name, mode string) error {
|
||
if name == "" {
|
||
return fmt.Errorf("tool: set execution mode: %w", ErrUnknownTool)
|
||
}
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if mode == "" || mode == ExecutionModeParallel {
|
||
delete(d.modes, name)
|
||
return nil
|
||
}
|
||
d.modes[name] = mode
|
||
return nil
|
||
}
|
||
|
||
// IsSequential reports whether the named tool has been declared sequential. An
|
||
// unknown name or one never marked sequential reports false (the default).
|
||
func (d *Dispatcher) IsSequential(name string) bool {
|
||
d.mu.RLock()
|
||
defer d.mu.RUnlock()
|
||
return d.modes[name] == ExecutionModeSequential
|
||
}
|
||
|
||
// PolicyEngine returns a restrict.Policy scoped to env, reading the editable
|
||
// reachability markers from the underlying capability registry (design/11 §3).
|
||
// It is the loop's authoritative source for the group/stranger tool gate and is
|
||
// fail-closed: a nil receiver or nil registry denies every tool in a non-owner
|
||
// environment, and the owner environment always allows. The returned policy is
|
||
// immutable and safe for concurrent use.
|
||
func (d *Dispatcher) PolicyEngine(env restrict.ActorEnv) *restrict.Policy {
|
||
if d == nil || d.reg == nil {
|
||
return restrict.SetContext(nil, env)
|
||
}
|
||
return restrict.SetContext(d.reg, env)
|
||
}
|
||
|
||
// Invoke executes the tool registered as name with the given args. It resolves
|
||
// the tool from the registry, validates args against the tool's Parameters JSON
|
||
// Schema, and runs Execute under the tool's deadline (per-tool override, else
|
||
// default). Any failure — unknown tool, invalid args, timeout, panic or an
|
||
// Execute error — is returned as a *ToolError wrapping the matching sentinel,
|
||
// so it is safe to feed back to the LLM.
|
||
//
|
||
// A call outside the round's allow set (mount/publish with
|
||
// WithRoundAllowSet/PublishRoundAllowSet, see allowset.go) is also returned as a
|
||
// *ToolError-free soft result: the call still runs, and its value comes back as
|
||
// SoftNotAllowed carrying the not_allowed notice for the model (design/14 §4,
|
||
// 裁决 14.4.2).
|
||
//
|
||
// 参与文件级互斥的工具(实现 FileTargeter,见 fileclaims.go)在目标文件正被
|
||
// **另一条在途路**操作时,其调用**不会执行**,而是直接返回 ErrFileBusy(带文件
|
||
// 状态的中文文案)。它**不包 ToolError**:占用判定发生在执行之前,回灌给模型的
|
||
// 结果就该是一句话(ToolError 的 message+cause 渲染会把同一句话打印两遍);
|
||
// 循环的 errorKind 仍把它归为 execution,与执行面失败的分类一致。
|
||
// 无论如何都**不静默、不排队等待**。
|
||
func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]any) (any, error) {
|
||
if name == "" {
|
||
return nil, &ToolError{Name: name, Message: "tool name is empty", cause: ErrUnknownTool}
|
||
}
|
||
cap, err := d.reg.Get(name)
|
||
if err != nil {
|
||
return nil, &ToolError{Name: name, Message: "unknown tool", cause: ErrUnknownTool}
|
||
}
|
||
if cap.Kind != registry.KindTool {
|
||
return nil, &ToolError{Name: name, Message: "not a callable tool", cause: ErrUnknownTool}
|
||
}
|
||
tool, ok := cap.Value.(sdk.Tool)
|
||
if !ok {
|
||
return nil, &ToolError{Name: name, Message: "tool value is not an sdk.Tool", cause: ErrUnknownTool}
|
||
}
|
||
|
||
params, perr := tool.Parameters()
|
||
if perr != nil {
|
||
return nil, &ToolError{
|
||
Name: name,
|
||
Message: "invalid tool schema",
|
||
cause: fmt.Errorf("tool: parameters %q: %w", name, perr),
|
||
}
|
||
}
|
||
if verr := validateArgs(params, args); verr != nil {
|
||
return nil, &ToolError{
|
||
Name: name,
|
||
Message: verr.Error(),
|
||
cause: fmt.Errorf("tool: validate %q: %w", name, ErrInvalidArgs),
|
||
}
|
||
}
|
||
|
||
// 允许集前置软判(design/14 §4,裁决 14.4.2):越出本轮允许集的调用
|
||
// 不硬阻断(状态不是能力边界),但必须可观测、可回灌 —— 见 allowset.go。
|
||
notice := d.softJudge(ctx, name)
|
||
|
||
// 文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3):写类工具执行前先登记
|
||
// 本次要动的文件;被**另一条在途路**占住时立刻把带文件状态的 ErrFileBusy 交给
|
||
// 模型(不静默、不排队死等)。释放挂 defer:成功 / 报错 / 超时 / panic 都释放,
|
||
// 绝不把文件永久锁住。未挂路标识或工具未声明目标 ⇒ 完全不参与互斥,逐字不变。
|
||
release, cerr := d.claimFiles(ctx, name, tool, args)
|
||
if cerr != nil {
|
||
return nil, cerr
|
||
}
|
||
if release != nil {
|
||
defer release()
|
||
}
|
||
|
||
invokeCtx, cancel := d.invokeCtx(ctx, name)
|
||
defer cancel()
|
||
|
||
result, execErr := callTool(invokeCtx, tool, args)
|
||
if execErr != nil {
|
||
return nil, wrapInvokeErr(name, execErr)
|
||
}
|
||
if notice != "" {
|
||
return SoftNotAllowed{Tool: name, NotAllowed: notice, Result: result}, nil
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// invokeCtx derives the execution context for a tool, applying the tool's
|
||
// deadline when it is positive.
|
||
func (d *Dispatcher) invokeCtx(ctx context.Context, name string) (context.Context, context.CancelFunc) {
|
||
timeout := d.timeoutFor(name)
|
||
if timeout > 0 {
|
||
return context.WithTimeout(ctx, timeout)
|
||
}
|
||
return context.WithCancel(ctx)
|
||
}
|
||
|
||
// timeoutFor returns the per-tool deadline override, or the default.
|
||
func (d *Dispatcher) timeoutFor(name string) time.Duration {
|
||
d.mu.RLock()
|
||
defer d.mu.RUnlock()
|
||
if t, ok := d.timeouts[name]; ok {
|
||
return t
|
||
}
|
||
return d.dft
|
||
}
|
||
|
||
// wrapInvokeErr converts a raw execution error into a *ToolError, classifying a
|
||
// deadline exceeded as ErrTimeout and preserving every other cause so errors.As
|
||
// can recover the ToolError and errors.Is can reach the underlying error.
|
||
func wrapInvokeErr(name string, err error) error {
|
||
switch {
|
||
case errors.Is(err, context.DeadlineExceeded):
|
||
return &ToolError{Name: name, Message: "execution timed out", cause: ErrTimeout}
|
||
case errors.Is(err, context.Canceled):
|
||
return &ToolError{Name: name, Message: "execution canceled", cause: err}
|
||
default:
|
||
return &ToolError{Name: name, Message: err.Error(), cause: err}
|
||
}
|
||
}
|
||
|
||
// callTool runs tool.Execute in a goroutine so a tool that ignores its context
|
||
// is still interrupted by the deadline. The goroutine recovers panics, so a
|
||
// panicking tool never crashes the process. The result channel is buffered so a
|
||
// goroutine that outlives a timeout can still deliver without leaking a sender.
|
||
func callTool(ctx context.Context, tool sdk.Tool, args any) (any, error) {
|
||
type result struct {
|
||
val any
|
||
err error
|
||
}
|
||
ch := make(chan result, 1)
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
ch <- result{err: fmt.Errorf("tool panicked: %v", r)}
|
||
}
|
||
}()
|
||
val, err := tool.Execute(ctx, args)
|
||
ch <- result{val: val, err: err}
|
||
}()
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case r := <-ch:
|
||
return r.val, r.err
|
||
}
|
||
}
|
||
|
||
// ToolSchemas returns the registered function-call schemas in registration
|
||
// order, ready to be advertised to a model in a model.Request. A capability that
|
||
// is not a callable sdk.Tool, or whose Parameters() reports an error, is
|
||
// skipped. A nil registry returns nil. The returned slice is owned by the caller
|
||
// and safe to mutate.
|
||
func (d *Dispatcher) ToolSchemas() []model.ToolSpec {
|
||
if d.reg == nil {
|
||
return nil
|
||
}
|
||
caps := d.reg.List(registry.KindTool)
|
||
out := make([]model.ToolSpec, 0, len(caps))
|
||
for _, cap := range caps {
|
||
t, ok := cap.Value.(sdk.Tool)
|
||
if !ok {
|
||
continue
|
||
}
|
||
params, err := t.Parameters()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, model.ToolSpec{
|
||
Name: t.Name(),
|
||
Description: t.Description(),
|
||
Parameters: params,
|
||
})
|
||
}
|
||
return out
|
||
}
|