From 38b45279496491324d05b9376156e731336cae73 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(tool,loop):=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=BD=AF=E5=88=A4=E5=B9=B6=E5=9B=9E=EF=BC=88=E8=A3=81?= =?UTF-8?q?=E5=86=B3=2014.4.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 落点=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 例红 ⇒ 两侧各被独立覆盖 --- internal/runtime/loop/run.go | 6 + internal/runtime/loop/tool_policy.go | 61 ++++- .../runtime/loop/tool_policy_soft_test.go | 186 +++++++++++++++ internal/runtime/tool/allowset.go | 171 ++++++++++++++ internal/runtime/tool/allowset_test.go | 219 ++++++++++++++++++ internal/runtime/tool/tool.go | 36 +++ 6 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 internal/runtime/loop/tool_policy_soft_test.go create mode 100644 internal/runtime/tool/allowset.go create mode 100644 internal/runtime/tool/allowset_test.go diff --git a/internal/runtime/loop/run.go b/internal/runtime/loop/run.go index 789fab5..e8668af 100644 --- a/internal/runtime/loop/run.go +++ b/internal/runtime/loop/run.go @@ -7,6 +7,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) @@ -48,6 +49,11 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa // 会话 id 搭同一个 context,使 capacity_awaken 的发出能 // 定向到产出它的会话(design/14 §5)。 ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet}) + // 同时播种该 Run 的「本轮工具允许集」持有者(design/14 §4,裁决 + // 14.4.2):每轮由 restrictSchemas 发布模型可见的工具面, + // Dispatcher.Invoke 据此做前置软判。作用域同样限于本次 Run, + // 子代理 Run 各自播种一份,并发 run 绝不共享。 + ctx = tool.WithRoundAllowSet(ctx) ctx = withLoopSession(ctx, sessionID) l.emitTurnBoundary(ctx, model.StreamKindTurnStart) diff --git a/internal/runtime/loop/tool_policy.go b/internal/runtime/loop/tool_policy.go index 47bad72..b87bb82 100644 --- a/internal/runtime/loop/tool_policy.go +++ b/internal/runtime/loop/tool_policy.go @@ -8,8 +8,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) -// auditToolRejectNotAllowed 是群聊/陌生人环境中被 -// design/11 §3 安全门拒绝的工具调用的审计 "what"。 +// auditToolRejectNotAllowed 是「工具调用越出允许面」的审计 "what": +// 群聊/陌生人环境中被 design/11 §3 安全门拒绝的调用,以及 +// capacity 允许集之外的越界调用(design/14 §4,裁决 14.4.2)共用它, +// 因此审计轨迹与前端小字两侧不必各认一套标签。 const auditToolRejectNotAllowed = "tool.reject.not_allowed" // AuditEvent 向审计 sink 上报被安全门拦截的工具拒绝 @@ -17,12 +19,16 @@ const auditToolRejectNotAllowed = "tool.reject.not_allowed" // 工具调用时发出一个,使该拒绝可审计,且 // 前端可以渲染「xx 工具在群聊被拒」的小字。它经 // Options.OnAudit 接到 gateway 审计轨迹。 +// +// capacity 允许集之外的越界调用也走同一通道(见 softJudgeAudit): +// 区别只在 Result 文案 —— 安全门写的是拒绝,软判写的是被让行。 type AuditEvent struct { - // Who 是实施行为的 actor 环境标签("group_member"/"stranger")。 + // Who 是实施行为的 actor 环境标签("group_member"/"stranger"/"owner")。 Who string // What 是操作,如 "tool.reject.not_allowed"。 What string - // Result 是结果详情 —— 指名被拒工具的 not_allowed 提示。 + // Result 是结果详情 —— 被拦调用的 not_allowed 提示,或越界调用 + // 被让行时的 not_allowed 软提示。 Result string } @@ -32,7 +38,13 @@ type AuditEvent struct { // 关闭),而 owner 环境保留全集。它是对 capacity // 允许集视图的最终裁决 —— 是安全红线而非便利 // 提示 —— 因此它最后运行,且只能收缩暴露面。 +// +// 它同时把**收缩前**的工具面(即 capacity 过滤后的本轮允许集) +// 发布到该 Run 的允许集持有者上,供 Dispatcher.Invoke 的前置软判 +// 使用(design/14 §4,裁决 14.4.2);env 安全门是硬红线,不在软判 +// 范围内(它拦下的调用在 Invoke 之前就已跳过)。 func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec { + l.publishRoundAllowSet(ctx, d, schemas) policy := d.PolicyEngine(sessionEnvFrom(ctx).ActorEnv) kept := make([]model.ToolSpec, 0, len(schemas)) for _, s := range schemas { @@ -43,6 +55,47 @@ func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas return kept } +// publishRoundAllowSet 把本轮模型可见的工具面发布到该 Run 的允许集 +// 持有者上,并把分发器的软判 sink 接进本循环的审计通道 +// (design/14 §4,裁决 14.4.2)。发布的是 capacity 过滤后的 schema +// 名单 —— 判定「这次调用是否越界」以模型实际看到的那份工具面为准, +// 不另算第二套策略。ctx 未播种持有者(不经 capacity 过滤的内联流程) +// 或 dispatcher 缺失时为空操作。 +func (l *Loop) publishRoundAllowSet(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) { + if d == nil { + return + } + names := make([]string, 0, len(schemas)) + for _, s := range schemas { + names = append(names, s.Name) + } + tool.PublishRoundAllowSet(ctx, names, l.allowSetSource(ctx)) + d.SetSoftJudgeSink(l.softJudgeAudit) +} + +// allowSetSource 是本轮允许集的来源标签,只用于软判日志与提示文案, +// 使越界调用看得出是哪个 capacity 态收窄了工具面;capacity 未启用时 +// 标注 off(此时工具面没有被收敛,软判不会触发)。 +func (l *Loop) allowSetSource(ctx context.Context) string { + if l.capacity == nil || !l.capacity.Enabled() { + return "capacity:off" + } + return "capacity:" + string(capState(ctx).state) +} + +// softJudgeAudit 把 Dispatcher 的允许集软判记录接进既有审计通道 +// (design/11 §3 §8 的 AuditEvent → Options.OnAudit):与群聊/陌生人 +// 安全门复用同一个审计 "what" 与 sink,因此前端小字与审计轨迹都能 +// 看到越界调用。who 从该次调用的会话环境(actor 元数据)取,与安全门 +// 同一口径;result 写明调用是被让行而非被拒(文案由分发器给出)。 +func (l *Loop) softJudgeAudit(ctx context.Context, ev tool.SoftJudgeEvent) { + l.emitAudit(AuditEvent{ + Who: string(sessionEnvFrom(ctx).ActorEnv), + What: auditToolRejectNotAllowed, + Result: ev.Reason, + }) +} + // applyToolSecurityGate 拦截所有工具在 ctx 会话环境中 // 不被允许的调用(design/11 §3)。它就地把 actions 改写, // 把每个可运行的调用改为携带 not_allowed 提示的跳过, diff --git a/internal/runtime/loop/tool_policy_soft_test.go b/internal/runtime/loop/tool_policy_soft_test.go new file mode 100644 index 0000000..e3749e5 --- /dev/null +++ b/internal/runtime/loop/tool_policy_soft_test.go @@ -0,0 +1,186 @@ +package loop + +import ( + "context" + "strings" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited 锁住裁决 14.4.2 +// 的「加软判并回」全链路:闲聊轮只广告 quiet 允许集,模型却调了 +// 允许集外的 web-search —— 调用不被硬阻断(确实执行,能力≠边界), +// 但判定并回:not_allowed 提示随 tool result 回灌给模型, +// 同一条判定落进既有审计通道(design/11 §3 §8)。 +func TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited(t *testing.T) { + t.Parallel() + call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_soft_1", + Name: "web-search", + Args: map[string]any{"q": "天气"}, + }}} + ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} + td := dispatcherWith(t, + &mockTool{name: "terminal", result: "ok"}, + &mockTool{name: "web-search", result: "web result"}, + ) + a := newTestAgent(t, ad, td) + var mu sync.Mutex + var audited []AuditEvent + l := New(Options{ + Agent: a, + MaxRounds: 8, + Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), + OnStream: func(model.StreamEvent) {}, + OnAudit: func(ev AuditEvent) { + mu.Lock() + audited = append(audited, ev) + mu.Unlock() + }, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + // ① 不硬阻断:越界调用确实执行了。 + if res.ToolCallsExecuted != 1 { + t.Fatalf("ToolCallsExecuted = %d, want 1(软判不得阻断执行)", res.ToolCallsExecuted) + } + // ② 该轮模型只被广告了 quiet 允许集 —— 发布出去的允许集与它一致。 + if len(ad.streamReqs) == 0 { + t.Fatal("no streaming request captured") + } + set := toolSet(ad.streamReqs[0].Tools) + if !hasAll(set, "terminal") || set["web-search"] { + t.Fatalf("quiet tools = %v, want terminal only", toolNames(ad.streamReqs[0].Tools)) + } + // ③ 软判并回:模型收到的 tool result 带 not_allowed 提示与来源标签。 + last := ad.streamReqs[len(ad.streamReqs)-1].Messages + msg := last[len(last)-1] + if msg.Role != model.RoleTool { + t.Fatalf("last message role = %q, want tool", msg.Role) + } + text := msg.Content[0].Text + for _, want := range []string{"not_allowed", "web-search", "capacity:quiet", "web result"} { + if !strings.Contains(text, want) { + t.Fatalf("tool result %q missing %q", text, want) + } + } + // ④ 可观测:判定落进既有审计通道,前端小字与审计轨迹都能看到。 + mu.Lock() + defer mu.Unlock() + if len(audited) != 1 { + t.Fatalf("audit events = %d, want 1 (%+v)", len(audited), audited) + } + if audited[0].What != auditToolRejectNotAllowed { + t.Fatalf("audit what = %q, want %q", audited[0].What, auditToolRejectNotAllowed) + } + if audited[0].Who != "owner" { + t.Fatalf("audit who = %q, want owner", audited[0].Who) + } + for _, want := range []string{"not_allowed", "web-search"} { + if !strings.Contains(audited[0].Result, want) { + t.Fatalf("audit result = %q missing %q", audited[0].Result, want) + } + } +} + +// TestRun_InsideAllowSet_NoSoftJudge 是对照组:调用就在本轮允许集内时, +// 不产生软判 —— 结果不被包装、也不落审计(软判只在越界时说话)。 +func TestRun_InsideAllowSet_NoSoftJudge(t *testing.T) { + t.Parallel() + call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_soft_2", + Name: "terminal", + Args: map[string]any{}, + }}} + ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} + td := dispatcherWith(t, + &mockTool{name: "terminal", result: "ok"}, + &mockTool{name: "web-search", result: "web result"}, + ) + a := newTestAgent(t, ad, td) + var mu sync.Mutex + var audited []AuditEvent + l := New(Options{ + Agent: a, + MaxRounds: 8, + Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), + OnStream: func(model.StreamEvent) {}, + OnAudit: func(ev AuditEvent) { + mu.Lock() + audited = append(audited, ev) + mu.Unlock() + }, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ToolCallsExecuted != 1 { + t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) + } + last := ad.streamReqs[len(ad.streamReqs)-1].Messages + text := last[len(last)-1].Content[0].Text + if strings.Contains(text, "not_allowed") { + t.Fatalf("tool result %q must not carry a soft notice for an allowed call", text) + } + mu.Lock() + defer mu.Unlock() + if len(audited) != 0 { + t.Fatalf("audit events = %d, want 0 for an allowed call", len(audited)) + } +} + +// TestRun_CapacityDisabled_NoSoftJudge 守住 design/14 §1 红线:没有一个 +// 允许集被收敛(capacity 未启用)时,调用任何工具都不产生软判 —— +// 软判只描述「本轮允许集」,绝不把决策层变成能力边界。 +func TestRun_CapacityDisabled_NoSoftJudge(t *testing.T) { + t.Parallel() + call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_soft_3", + Name: "web-search", + Args: map[string]any{"q": "天气"}, + }}} + ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} + td := dispatcherWith(t, + &mockTool{name: "terminal", result: "ok"}, + &mockTool{name: "web-search", result: "web result"}, + ) + a := newTestAgent(t, ad, td) + var mu sync.Mutex + var audited []AuditEvent + l := New(Options{ + Agent: a, + MaxRounds: 8, + OnStream: func(model.StreamEvent) {}, + OnAudit: func(ev AuditEvent) { + mu.Lock() + audited = append(audited, ev) + mu.Unlock() + }, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ToolCallsExecuted != 1 { + t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) + } + last := ad.streamReqs[len(ad.streamReqs)-1].Messages + if text := last[len(last)-1].Content[0].Text; strings.Contains(text, "not_allowed") { + t.Fatalf("tool result %q must not carry a soft notice without an allow set", text) + } + mu.Lock() + defer mu.Unlock() + if len(audited) != 0 { + t.Fatalf("audit events = %d, want 0 without an allow set", len(audited)) + } +} diff --git a/internal/runtime/tool/allowset.go b/internal/runtime/tool/allowset.go new file mode 100644 index 0000000..29c7bdf --- /dev/null +++ b/internal/runtime/tool/allowset.go @@ -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" +} diff --git a/internal/runtime/tool/allowset_test.go b/internal/runtime/tool/allowset_test.go new file mode 100644 index 0000000..f4206ff --- /dev/null +++ b/internal/runtime/tool/allowset_test.go @@ -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") + } +} diff --git a/internal/runtime/tool/tool.go b/internal/runtime/tool/tool.go index 8bf3a5a..35218f9 100644 --- a/internal/runtime/tool/tool.go +++ b/internal/runtime/tool/tool.go @@ -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 }