feat(tool,loop): 工具调用软判并回(裁决 14.4.2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 落点=Dispatcher.Invoke 入口(唯一执行口,覆盖主循环/子代理/编排全部调用方)
- 语义=**不硬阻断**:越界调用照常执行,把 not_allowed 提示与软判理由挂到真实结果上回灌模型
  (符合 design/14 §1「状态改变不构成能力边界、紧急时也能调」与 §4「入口前置软判」)
- 允许集单一真源:loop 发布 capacity 过滤后的 schema 名单(= 模型本轮实际看到的工具面),
  分发器不在本地另算一套判定
- 审计:复用既有 AuditEvent/OnAudit 通道(what=tool.reject.not_allowed,design/14 §4 原文),
  前端小字与审计日志同源;日志带 source=runtime/tool
- ctx 载体:Run 入口每 Run 播种允许集持有者(run.go +6 行,越界但必需——ctx 只能由 Run 或
  request.go 播种,后者归 G5-A)
- 与既有硬闸门零重叠:restrict(群聊/陌生人)与 ToolWhitelist(子代理)仍在更早处硬拦 + 各自审计
- 复核:`go build`/`go vet`/tool+loop 包测试绿,`-race` 下 SoftJudge/AllowSet 无竞态;
  变异 M1(软判恒空)4 例红、M2(不发布允许集)仅端到端 1 例红 ⇒ 两侧各被独立覆盖
This commit is contained in:
AskaEth committed 2026-10-04 12:45:48 +08:00
1 parent 3972ea4007
commit 38b4527949
6 files changed
+675 -4

No files matched your search

+36
View File
@@ -13,11 +13,14 @@ 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"
)
@@ -106,9 +109,12 @@ type Option func(*Dispatcher)
type Dispatcher struct {
reg *registry.Registry
dft time.Duration
log *slog.Logger
mu sync.RWMutex // guards timeouts and modes
timeouts map[string]time.Duration
modes map[string]string // tool name -> ExecutionMode* (default parallel)
sinkMu sync.RWMutex // guards the soft-judge sink
sink SoftJudgeSink
}
// New builds a Dispatcher backed by reg, applying opts in order. It returns an
@@ -121,6 +127,7 @@ func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
d := &Dispatcher{
reg: reg,
dft: DefaultTimeout,
log: sourcedLog(nil),
timeouts: make(map[string]time.Duration),
modes: make(map[string]string),
}
@@ -138,6 +145,22 @@ 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) }
}
// 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.
@@ -207,6 +230,12 @@ func (d *Dispatcher) PolicyEngine(env restrict.ActorEnv) *restrict.Policy {
// 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).
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}
@@ -239,6 +268,10 @@ func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]an
}
}
// 允许集前置软判(design/14 §4,裁决 14.4.2):越出本轮允许集的调用
// 不硬阻断(状态不是能力边界),但必须可观测、可回灌 —— 见 allowset.go。
notice := d.softJudge(ctx, name)
invokeCtx, cancel := d.invokeCtx(ctx, name)
defer cancel()
@@ -246,6 +279,9 @@ func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]an
if execErr != nil {
return nil, wrapInvokeErr(name, execErr)
}
if notice != "" {
return SoftNotAllowed{Tool: name, NotAllowed: notice, Result: result}, nil
}
return result, nil
}