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

+171
View File
@@ -0,0 +1,171 @@
package tool
import (
"context"
"sync"
)
// 本文件承载「本轮允许集」的软判通道(DevDocs/design/14-唤醒式能力渐进.md
// §4,裁决 14.4.2「加软判并回」)。
//
// 背景:capacity 决策层每轮只把允许集内的工具 schema 广告给模型,而
// Dispatcher.Invoke 不做硬过滤 —— 状态改变不构成能力边界,静息态遇到
// 紧急情况也必须能调任何工具(design/14 §1 红线)。于是「模型调了本轮
// 没点亮的工具」既不能拦、也不能当没发生:Invoke 入口据此做一次
// **前置软判** —— 记一条带来源的日志、把记录交给接线方(循环把它接进
// 既有审计通道),并把 not_allowed 提示随结果一起回灌给模型。
// 调用照常执行,软判绝不变成悄悄放行,也绝不硬阻断。
// roundAllowSet 是本 Run 的允许集持有者:由 Run 播种一次,每轮由循环
// 发布「本轮模型可见的工具面」。同一 Run 内轮次串行,因此单份持有者
// 足够;它经 ctx 随 Run 走,子代理 Run 各自播种一份,并发 Run 互不串扰。
type roundAllowSet struct {
mu sync.RWMutex
names []string
source string
published bool
}
// roundAllowSetKey 是允许集持有者的 context key。
type roundAllowSetKey struct{}
// WithRoundAllowSet 每 Run 播种一份允许集持有者。它总是新建持有者(不
// 做幂等复用):子代理 Run 的工具面与父级不同,复用父级的持有者会把
// 子代理的调用判成父级的允许集。
func WithRoundAllowSet(ctx context.Context) context.Context {
if ctx == nil {
return nil
}
return context.WithValue(ctx, roundAllowSetKey{}, &roundAllowSet{})
}
// PublishRoundAllowSet 把本轮模型可见的工具面发布到该 Run 的持有者上
// (循环组装本轮请求时调用)。names 会被复制,调用方后续修改不影响已
// 发布值;source 是本轮允许集的来源标签(如 "capacity:quiet"),只用于
// 日志与提示文案,不参与判定。ctx 上未播种持有者时为空操作 ——
// 未发布即「本轮没有做允许集收敛」(例如不经 capacity 过滤的内联流程),
// 此时 Invoke 不做软判,行为与接通前逐字节一致。
func PublishRoundAllowSet(ctx context.Context, names []string, source string) {
h := roundAllowSetHolder(ctx)
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.names = append([]string(nil), names...)
h.source = source
h.published = true
}
// roundAllowSetHolder 返回 ctx 上的允许集持有者,未播种时为 nil。
func roundAllowSetHolder(ctx context.Context) *roundAllowSet {
if ctx == nil {
return nil
}
h, _ := ctx.Value(roundAllowSetKey{}).(*roundAllowSet)
return h
}
// roundAllowSetFrom 返回 ctx 上已发布的允许集;未播种或未发布时
// ok=false,语义为「本轮不做软判」。
func roundAllowSetFrom(ctx context.Context) (names []string, source string, ok bool) {
h := roundAllowSetHolder(ctx)
if h == nil {
return nil, "", false
}
h.mu.RLock()
defer h.mu.RUnlock()
if !h.published {
return nil, "", false
}
return append([]string(nil), h.names...), h.source, true
}
// allowsName 报告 name 是否在允许集内。
func allowsName(names []string, name string) bool {
for _, n := range names {
if n == name {
return true
}
}
return false
}
// SoftNotAllowed 是「软判并回」的调用结果:调用确实执行了(能力≠边界,
// 不硬阻断),但结果里显式带上 not_allowed 提示,使模型经 tool result
// 看到这次调用越出了本轮的允许集,trace 与回灌也都可查。
type SoftNotAllowed struct {
// Tool 是越界调用的工具名。
Tool string `json:"tool"`
// NotAllowed 是回灌给模型的软提示文案(带 not_allowed 前缀)。
NotAllowed string `json:"not_allowed"`
// Result 是该工具的真实返回值 —— 调用被执行过。
Result any `json:"result"`
}
// SoftJudgeEvent 是一次软判的记录,交给接线方(循环)落审计。
type SoftJudgeEvent struct {
// Tool 是越界调用的工具名。
Tool string
// Source 是本轮允许集的来源标签(如 "capacity:quiet")。
Source string
// Reason 是回灌给模型的软提示文案。
Reason string
}
// SoftJudgeSink 接收软判记录。ctx 是发起该次调用的上下文,接线方可据
// 会话环境(actor 元数据)补出审计的 who。
type SoftJudgeSink func(ctx context.Context, ev SoftJudgeEvent)
// SetSoftJudgeSink 接线软判 sink:把「越出允许集但被让行」的调用接进
// 审计通道(design/11 §3 §8 的 AuditEvent / OnAudit)。同一 dispatcher
// 只保留最后一个接线方 —— 一个 dispatcher 归一个循环所有(子代理循环
// 是主循环的浅拷贝,OnAudit 相同),多循环共用同一 dispatcher 时以最后
// 接线者为准。sink 为 nil 即注销。
func (d *Dispatcher) SetSoftJudgeSink(sink SoftJudgeSink) {
if d == nil {
return
}
d.sinkMu.Lock()
defer d.sinkMu.Unlock()
d.sink = sink
}
// notifySoftJudge 把一次软判记录交给接线方(未接线时为空操作)。
func (d *Dispatcher) notifySoftJudge(ctx context.Context, ev SoftJudgeEvent) {
if d == nil {
return
}
d.sinkMu.RLock()
sink := d.sink
d.sinkMu.RUnlock()
if sink != nil {
sink(ctx, ev)
}
}
// softJudge 判定一次调用是否越出本轮允许集(design/14 §4,裁决 14.4.2)。
// 返回空串表示无需软判(本轮未发布允许集,或调用就在允许集内)。越界时
// 它记一条带来源的日志、把记录交给 sink,并返回回灌给模型的软提示。
// 它绝不返回错误、绝不拦截调用:状态变化不是能力边界。
func (d *Dispatcher) softJudge(ctx context.Context, name string) string {
names, source, ok := roundAllowSetFrom(ctx)
if !ok || allowsName(names, name) {
return ""
}
reason := softNotAllowedReason(name, source)
d.log.Warn("tool call outside the capacity allow set (soft judge, not blocked)",
"tool", name, "allow_set_source", source, "allow_set_size", len(names))
d.notifySoftJudge(ctx, SoftJudgeEvent{Tool: name, Source: source, Reason: reason})
return reason
}
// softNotAllowedReason 是越界调用回灌给模型的软提示文案:写明它没有被
// 拒绝、仍然会被执行,避免模型把软判误读成能力边界。
func softNotAllowedReason(name, source string) string {
if source == "" {
source = "unknown"
}
return "not_allowed: tool " + name + " is outside this round's allow set (" + source +
"); the call was still let through; capacity is not a capability boundary"
}
+219
View File
@@ -0,0 +1,219 @@
package tool
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"testing"
)
// softTool 是软判用例的夹具:它记录自己是否真的被执行过,
// 使「软判不硬阻断」可以被直接断言,而不是靠返回值反推。
type softTool struct {
name string
mu sync.Mutex
ran int
}
func (s *softTool) Name() string { return s.name }
func (s *softTool) Description() string { return "soft judge fixture " + s.name }
func (s *softTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object", "properties": map[string]any{}}, nil
}
func (s *softTool) Execute(_ context.Context, _ any) (any, error) {
s.mu.Lock()
s.ran++
s.mu.Unlock()
return "ran:" + s.name, nil
}
// runs 返回该工具被执行过的次数。
func (s *softTool) runs() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.ran
}
// softFixture 构建一个带两个工具的 dispatcher:terminal 在允许集内,
// web-search 在外,并返回二者与软判记录收集器。
func softFixture(t *testing.T, opts ...Option) (*Dispatcher, *softTool, *softTool, func() []SoftJudgeEvent) {
t.Helper()
d, _ := newDispatcher(t, opts...)
inside := &softTool{name: "terminal"}
outside := &softTool{name: "web-search"}
for _, tk := range []*softTool{inside, outside} {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %q: %v", tk.name, err)
}
}
var mu sync.Mutex
var events []SoftJudgeEvent
d.SetSoftJudgeSink(func(_ context.Context, ev SoftJudgeEvent) {
mu.Lock()
events = append(events, ev)
mu.Unlock()
})
read := func() []SoftJudgeEvent {
mu.Lock()
defer mu.Unlock()
return append([]SoftJudgeEvent(nil), events...)
}
return d, inside, outside, read
}
// TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked 锁住裁决 14.4.2
// 的「软判并回」:越出本轮允许集的调用不被硬阻断(确实执行了,
// 能力≠边界),但结果里显式带上 not_allowed 提示回灌给模型,
// 并留下一条可观测的判定记录(sink + 带来源的日志见下一个用例)。
func TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked(t *testing.T) {
t.Parallel()
d, _, outside, events := softFixture(t)
ctx := WithRoundAllowSet(context.Background())
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
got, err := d.Invoke(ctx, "web-search", map[string]any{"q": "天气"})
if err != nil {
t.Fatalf("Invoke outside the allow set must not hard-block, got %v", err)
}
if outside.runs() != 1 {
t.Fatalf("tool ran %d times, want 1 (软判不得阻断执行)", outside.runs())
}
soft, ok := got.(SoftNotAllowed)
if !ok {
t.Fatalf("result = %#v, want SoftNotAllowed(软结果并回)", got)
}
if soft.Tool != "web-search" {
t.Fatalf("soft result tool = %q, want web-search", soft.Tool)
}
if soft.Result != "ran:web-search" {
t.Fatalf("soft result payload = %#v, want the real tool result", soft.Result)
}
for _, want := range []string{"not_allowed", "web-search", "capacity:quiet"} {
if !strings.Contains(soft.NotAllowed, want) {
t.Fatalf("notice %q missing %q", soft.NotAllowed, want)
}
}
read := events()
if len(read) != 1 {
t.Fatalf("soft judge events = %d, want 1 (%+v)", len(read), read)
}
if read[0].Tool != "web-search" || read[0].Source != "capacity:quiet" {
t.Fatalf("event = %+v, want tool/source recorded", read[0])
}
if read[0].Reason != soft.NotAllowed {
t.Fatalf("event reason = %q, want the model-facing notice %q", read[0].Reason, soft.NotAllowed)
}
}
// TestInvoke_InsideAllowSet_ResultUntouched 证明允许集内的调用
// 逐字节保持既有行为:原始结果、不包装、无软判记录。
func TestInvoke_InsideAllowSet_ResultUntouched(t *testing.T) {
t.Parallel()
d, inside, _, events := softFixture(t)
ctx := WithRoundAllowSet(context.Background())
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
got, err := d.Invoke(ctx, "terminal", map[string]any{})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if got != "ran:terminal" {
t.Fatalf("result = %#v, want the raw tool result", got)
}
if inside.runs() != 1 {
t.Fatalf("tool ran %d times, want 1", inside.runs())
}
if n := len(events()); n != 0 {
t.Fatalf("soft judge events = %d, want 0 for an allowed call", n)
}
}
// TestInvoke_NoAllowSetPublished_Unchanged 证明「未发布允许集」语义
// 为不做软判:未播种,以及播种但从未发布,两条路径都与接通前一致。
func TestInvoke_NoAllowSetPublished_Unchanged(t *testing.T) {
t.Parallel()
d, _, _, events := softFixture(t)
// ① 完全没播种。
got, err := d.Invoke(context.Background(), "web-search", map[string]any{})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if got != "ran:web-search" {
t.Fatalf("result = %#v, want the raw tool result", got)
}
// ② 播种了但本轮没有发布(例如不经 capacity 过滤的内联流程)。
seeded := WithRoundAllowSet(context.Background())
if _, err := d.Invoke(seeded, "web-search", map[string]any{}); err != nil {
t.Fatalf("Invoke: %v", err)
}
if n := len(events()); n != 0 {
t.Fatalf("soft judge events = %d, want 0 without a published allow set", n)
}
}
// TestSoftJudge_LogsWithSource 证明软判留下可观测的判定记录,
// 且日志带模块来源(项目规范 §10),不是悄悄放行。
func TestSoftJudge_LogsWithSource(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
d, _, _, _ := softFixture(t, WithLogger(slog.New(slog.NewJSONHandler(&buf, nil))))
ctx := WithRoundAllowSet(context.Background())
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil {
t.Fatalf("Invoke: %v", err)
}
line := buf.String()
for _, want := range []string{`"source":"runtime/tool"`, "web-search", "capacity:quiet", "soft judge"} {
if !strings.Contains(line, want) {
t.Fatalf("log %q missing %q", line, want)
}
}
}
// TestPublishRoundAllowSet_CopiesNames 证明发布时会复制名单:
// 调用方随后改自己那份切片,不得影响已发布的允许集。
func TestPublishRoundAllowSet_CopiesNames(t *testing.T) {
t.Parallel()
d, _, _, events := softFixture(t)
names := []string{"terminal"}
ctx := WithRoundAllowSet(context.Background())
PublishRoundAllowSet(ctx, names, "capacity:quiet")
names[0] = "web-search"
// 允许集仍是 [terminal],因此 web-search 依旧越界。
if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil {
t.Fatalf("Invoke: %v", err)
}
if n := len(events()); n != 1 {
t.Fatalf("soft judge events = %d, want 1 (published names must be a copy)", n)
}
}
// TestWithRoundAllowSet_FreshHolderPerRun 证明重新播种得到独立持有者:
// 子代理 Run 不得继承父级 Run 已发布的允许集(两者的工具面不同)。
func TestWithRoundAllowSet_FreshHolderPerRun(t *testing.T) {
t.Parallel()
outer := WithRoundAllowSet(context.Background())
PublishRoundAllowSet(outer, []string{"terminal"}, "capacity:quiet")
inner := WithRoundAllowSet(outer)
if _, _, ok := roundAllowSetFrom(inner); ok {
t.Fatal("nested run must not inherit the parent's published allow set")
}
if names, source, ok := roundAllowSetFrom(outer); !ok || len(names) != 1 || source != "capacity:quiet" {
t.Fatalf("outer allow set = %v/%q/%v, want it untouched", names, source, ok)
}
if WithRoundAllowSet(nil) != nil {
t.Fatal("WithRoundAllowSet(nil) must stay nil")
}
}
+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
}