feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词) #2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例 #3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注) #6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload) 管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响 全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐 备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
This commit is contained in:
1 parent
aedae2190b
commit
7e39153a69
24 files changed
+1286
-56
No files matched your search
@@ -86,12 +86,11 @@ func TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead(t *testing.T) {
|
||||
t.Fatal("no streaming request captured")
|
||||
}
|
||||
set := toolSet(ad.streamReqs[0].Tools)
|
||||
// Restricted env → L4 silent → undecided turn stays quiet → only quiet tools, no
|
||||
// web-search, no owner habit read.
|
||||
if !hasAll(set, "terminal", "file") {
|
||||
t.Fatalf("restricted tools = %v, want quiet set terminal+file", toolNames(ad.streamReqs[0].Tools))
|
||||
}
|
||||
if set["web-search"] {
|
||||
t.Fatalf("restricted tools = %v, must NOT wake (owner habit not read)", toolNames(ad.streamReqs[0].Tools))
|
||||
// Restricted env → L4 silent → the turn stays undecided, and the design/11 §3
|
||||
// security gate hides every tool that is not marked AllowInGroup (terminal/file
|
||||
// are unmarked). So the model sees nothing: web-search is absent (owner habit
|
||||
// not read) and even the quiet-set tools are closed to a group session.
|
||||
if len(set) != 0 {
|
||||
t.Fatalf("restricted tools = %v, want none (security gate closes unmarked tools)", toolNames(ad.streamReqs[0].Tools))
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,26 @@ package loop
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
||||
)
|
||||
|
||||
// checkpointStore is the minimal surface the loop needs to persist a durable
|
||||
// snapshot and resume an interrupted run (design §1 durable execution).
|
||||
// *persist.Store satisfies it in production; tests substitute a fake to assert
|
||||
// the resume path without a real database.
|
||||
type checkpointStore interface {
|
||||
// Save upserts a checkpoint.
|
||||
Save(ctx context.Context, cp *persist.Checkpoint) error
|
||||
// LoadLatest returns the most recently updated checkpoint for agentID, or
|
||||
// ErrNotFound when the agent has none.
|
||||
LoadLatest(ctx context.Context, agentID string) (*persist.Checkpoint, error)
|
||||
}
|
||||
|
||||
// checkpointState is the durable snapshot persisted at every step. Carrying the
|
||||
// message window and the step index lets a long-running task be resumed from the
|
||||
// latest checkpoint without losing context (design §1 durable execution).
|
||||
@@ -44,3 +58,59 @@ func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, m
|
||||
l.log.Warn("checkpoint save", "step", step, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadCheckpoint resumes an interrupted run from the session's latest durable
|
||||
// checkpoint (design §1 durable execution). It returns the restored message
|
||||
// window, the next loop step to run (the checkpoint step + 1) and whether a
|
||||
// resumable checkpoint was found. When no checkpoint exists, or the latest one
|
||||
// reflects a completed/blocked run, it returns the caller's messages with step 1
|
||||
// so the loop starts fresh.
|
||||
//
|
||||
// Restoration is idempotent: the checkpoint captures the message window exactly as
|
||||
// it stood at the end of the last completed step (assistant tool calls plus their
|
||||
// results are already fused in), so resuming at the next step rebuilds the model
|
||||
// context from history and never re-executes a completed step's tool call nor
|
||||
// regenerates a finished answer. Loading is read-only — a resume never deletes the
|
||||
// checkpoint, so every step's snapshot stays queryable.
|
||||
func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []model.Message) ([]model.Message, int, bool) {
|
||||
if l.checkpoint == nil {
|
||||
return messages, 1, false
|
||||
}
|
||||
cp, err := l.checkpoint.LoadLatest(ctx, sessionID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, persist.ErrNotFound) {
|
||||
l.log.Warn("checkpoint load", "session", sessionID, "error", err)
|
||||
}
|
||||
return messages, 1, false
|
||||
}
|
||||
st := checkpointState{}
|
||||
if err := json.Unmarshal(cp.State, &st); err != nil {
|
||||
l.log.Warn("checkpoint unmarshal", "session", sessionID, "step", cp.Step, "error", err)
|
||||
return messages, 1, false
|
||||
}
|
||||
if !resumableStopReason(st.StopReason) {
|
||||
l.log.Debug("checkpoint not resumable", "session", sessionID, "step", cp.Step, "stop_reason", st.StopReason)
|
||||
return messages, 1, false
|
||||
}
|
||||
l.log.Info("checkpoint resumed", "session", sessionID, "step", cp.Step, "next_step", cp.Step+1, "messages", len(st.Messages))
|
||||
return st.Messages, cp.Step + 1, true
|
||||
}
|
||||
|
||||
// resumableStopReason reports whether a checkpoint whose last step ended with the
|
||||
// given stop reason represents an in-flight run worth resuming. Only a
|
||||
// mid-tool-loop step is genuinely "interrupted": the loop folded the tool results
|
||||
// into the window and was about to continue, so picking up at the next step
|
||||
// rebuilds context without duplicating work. A checkpoint carrying a final answer,
|
||||
// a budget exhaustion, a safety-net termination or a guardrail denial is a
|
||||
// completed/blocked run — resuming it would regenerate an already-produced answer
|
||||
// (a duplicate side effect) — so those fall through to a fresh start (a caller
|
||||
// opening a genuinely new conversation uses a distinct sessionID, which misses
|
||||
// LoadLatest anyway).
|
||||
func resumableStopReason(stopReason string) bool {
|
||||
switch stopReason {
|
||||
case "", agent.StopReasonToolCalls:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
@@ -89,7 +88,7 @@ type Loop struct {
|
||||
retriever *memory.Retriever
|
||||
working *memory.WorkingMemory
|
||||
extractor memory.Extractor
|
||||
checkpoint *persist.Store
|
||||
checkpoint checkpointStore
|
||||
tracer *trace.Tracer
|
||||
guards *guardrail.Pipeline
|
||||
personaID string
|
||||
@@ -100,6 +99,7 @@ type Loop struct {
|
||||
toolParallelism int
|
||||
hooks ToolLoopHooks
|
||||
onStream func(model.StreamEvent)
|
||||
onAudit func(AuditEvent)
|
||||
maxContextTokens int
|
||||
compactor Compactor
|
||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||
@@ -150,6 +150,7 @@ func New(opts Options) *Loop {
|
||||
toolParallelism: toolParallelism,
|
||||
hooks: opts.Hooks,
|
||||
onStream: opts.OnStream,
|
||||
onAudit: opts.OnAudit,
|
||||
maxContextTokens: opts.MaxContextTokens,
|
||||
compactor: compactor,
|
||||
transformContext: opts.TransformContext,
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
@@ -38,6 +37,12 @@ type Options struct {
|
||||
// loop observes while deciding a round (design/14 §4), so a caller can
|
||||
// surface the "能力点亮" progression to the frontend. Nil = no-op.
|
||||
OnCapacity func(capacity.Event)
|
||||
// OnAudit, when non-nil, receives an AuditEvent every time the loop denies a
|
||||
// tool call in a restricted (group/stranger) environment (design/11 §3 §8),
|
||||
// so the rejection lands in the audit trail and the frontend can render the
|
||||
// small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the
|
||||
// model as a soft not_allowed result, just not recorded to an external sink.
|
||||
OnAudit func(AuditEvent)
|
||||
// Memory is the long-term memory store used to persist extracted items.
|
||||
Memory *memory.Store
|
||||
// Retriever injects relevant memories into the context, re-evaluated every
|
||||
@@ -48,8 +53,11 @@ type Options struct {
|
||||
WorkingMemory *memory.WorkingMemory
|
||||
// Extractor derives memory items to persist after every step.
|
||||
Extractor memory.Extractor
|
||||
// Checkpoint persists a durable snapshot of the loop state at every step.
|
||||
Checkpoint *persist.Store
|
||||
// Checkpoint persists a durable snapshot of the loop state at every step and,
|
||||
// on a later Run with the same sessionID, resumes an interrupted run from it
|
||||
// (design §1 durable execution). *persist.Store satisfies the narrow
|
||||
// checkpointStore interface; nil disables both persistence and resume.
|
||||
Checkpoint checkpointStore
|
||||
// Tracer emits a structured span for every step, LLM call and tool call.
|
||||
Tracer *trace.Tracer
|
||||
// Guards validates request/response payloads on the streaming path, where
|
||||
|
||||
@@ -58,6 +58,12 @@ func (l *Loop) temperature() *float64 {
|
||||
// (a view over the dispatcher, per design/14 §4): the model sees/invokes only
|
||||
// the allowed tools, while Invoke itself stays unblocked. A nil or disabled
|
||||
// decider keeps the pre-existing behavior (every tool advertised).
|
||||
//
|
||||
// The design/11 §3 security gate runs last over whatever the capacity view left:
|
||||
// in a group/stranger environment only tools whose editable marker allows that
|
||||
// environment are advertised (default closed), and the owner environment keeps
|
||||
// the full set. Security is a hard red line, not a convenience hint, so it can
|
||||
// only shrink the surface — it never re-admits a tool capacity excluded.
|
||||
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
|
||||
d := l.dispatcher()
|
||||
if d == nil {
|
||||
@@ -65,7 +71,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
}
|
||||
schemas := d.ToolSchemas()
|
||||
if l.capacity == nil || !l.capacity.Enabled() {
|
||||
return schemas
|
||||
return l.restrictSchemas(ctx, d, schemas)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(schemas))
|
||||
@@ -81,7 +87,9 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
})
|
||||
acc.state = dec.State
|
||||
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
|
||||
sessionID := loopSession(ctx)
|
||||
for _, ev := range dec.Events {
|
||||
ev.SessionID = sessionID
|
||||
l.onCapacity(ev)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +104,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return l.restrictSchemas(ctx, d, out)
|
||||
}
|
||||
|
||||
// capacityTexts extracts the recent conversation text for the capacity decider:
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
||||
)
|
||||
|
||||
// fakeCheckpointStore is a seam for asserting the resume path without a real
|
||||
// database. It records every LoadLatest call and returns a scripted checkpoint or
|
||||
// error, so a test can prove the loop asks the store for the session's checkpoint
|
||||
// and resumes (or stays fresh) based on what comes back.
|
||||
type fakeCheckpointStore struct {
|
||||
latest *persist.Checkpoint
|
||||
latestErr error
|
||||
loadCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeCheckpointStore) Save(context.Context, *persist.Checkpoint) error { return nil }
|
||||
|
||||
func (f *fakeCheckpointStore) LoadLatest(_ context.Context, agentID string) (*persist.Checkpoint, error) {
|
||||
f.loadCalls = append(f.loadCalls, agentID)
|
||||
return f.latest, f.latestErr
|
||||
}
|
||||
|
||||
// TestRun_ResumeFromCheckpoint_ContinuesNoRepeat simulates a long task that was
|
||||
// interrupted mid-tool-loop: the loop left a tool_calls checkpoint at step 2 with
|
||||
// the message window as it stood after folding two tool batches in, then the
|
||||
// process died before step 3 could run. A fresh Run on the same sessionID must
|
||||
// resume at step 3 — rebuilding the context from the checkpoint rather than
|
||||
// restarting — and must not re-execute the already-completed steps.
|
||||
func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
cp := newTestCheckpointStore(t)
|
||||
l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8})
|
||||
|
||||
const sess = "sess-resume"
|
||||
interrupted := []model.Message{
|
||||
model.TextMessage(model.RoleUser, "do the task"),
|
||||
model.TextMessage(model.RoleTool, "tool aa result: va"),
|
||||
model.TextMessage(model.RoleTool, "tool bb result: vb"),
|
||||
}
|
||||
// Seed exactly what a crashed run leaves behind: a tool_calls checkpoint at
|
||||
// step 2 with the window computed after step 2's tool batch.
|
||||
l.saveCheckpoint(ctx, sess, 2, interrupted, agent.StopReasonToolCalls)
|
||||
|
||||
res, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "do the task")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// Resume continues at step 3 (the interrupted step), not step 1.
|
||||
if res.Steps != 3 {
|
||||
t.Fatalf("Steps = %d, want 3 (resumed at step 3)", res.Steps)
|
||||
}
|
||||
// No completed step's tool call is re-executed.
|
||||
if res.ToolCallsExecuted != 0 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 0 (completed steps not re-run)", res.ToolCallsExecuted)
|
||||
}
|
||||
if res.Text != "final" {
|
||||
t.Fatalf("Text = %q, want final", res.Text)
|
||||
}
|
||||
|
||||
// The resumed run makes exactly one model call (step 3), fed the restored
|
||||
// window — not a restart with a fresh conversation.
|
||||
if len(ad.reqs) != 1 {
|
||||
t.Fatalf("resumed run made %d model calls, want 1", len(ad.reqs))
|
||||
}
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != len(interrupted)+1 {
|
||||
t.Fatalf("resumed request messages = %d, want %d (system + restored window)", len(got), len(interrupted)+1)
|
||||
}
|
||||
if got[0].Role != model.RoleSystem {
|
||||
t.Fatalf("leading message role = %q, want system (instruction)", got[0].Role)
|
||||
}
|
||||
for i, m := range interrupted {
|
||||
if got[i+1].Role != m.Role || messageText(got[i+1]) != messageText(m) {
|
||||
t.Fatalf("restored message %d mismatch: got %+v, want %+v", i, got[i+1], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NewSession_NotResumed proves a different sessionID never resurrects an
|
||||
// old checkpoint: the caller opens a brand-new session, LoadLatest misses, and the
|
||||
// loop runs fresh from step 1 with the caller's own messages.
|
||||
func TestRun_NewSession_NotResumed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "done"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
||||
a := newTestAgent(t, ad, td)
|
||||
cp := newTestCheckpointStore(t)
|
||||
l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8})
|
||||
|
||||
// sess-a holds an interrupted run's checkpoint.
|
||||
l.saveCheckpoint(ctx, "sess-a", 2, []model.Message{
|
||||
model.TextMessage(model.RoleUser, "a task"),
|
||||
model.TextMessage(model.RoleTool, "tool echo result: hi"),
|
||||
}, agent.StopReasonToolCalls)
|
||||
|
||||
res, err := l.Run(ctx, "sess-b", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Steps != 2 {
|
||||
t.Fatalf("Steps = %d, want 2 (fresh two-step run)", res.Steps)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||||
}
|
||||
// The first model call must start from the caller's fresh message, never from
|
||||
// sess-a's restored window.
|
||||
first := ad.reqs[0].Messages
|
||||
if len(first) != 2 {
|
||||
t.Fatalf("fresh run first request messages = %d, want 2 (system + user)", len(first))
|
||||
}
|
||||
if messageText(first[1]) != "go" {
|
||||
t.Fatalf("fresh run first user message = %q, want 'go'", messageText(first[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_LoadLatestCalled asserts Run asks the store for the session's
|
||||
// latest checkpoint and resumes from it (LoadLatest → tool_calls checkpoint →
|
||||
// continue at the next step).
|
||||
func TestRun_Resume_LoadLatestCalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
buf, err := json.Marshal(checkpointState{
|
||||
Step: 2,
|
||||
Messages: []model.Message{model.TextMessage(model.RoleUser, "keep going")},
|
||||
StopReason: agent.StopReasonToolCalls,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal checkpoint: %v", err)
|
||||
}
|
||||
fake := &fakeCheckpointStore{latest: &persist.Checkpoint{ID: "sess:2", AgentID: "sess", Step: 2, State: buf}}
|
||||
l := New(Options{Agent: a, Checkpoint: fake, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "new")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fake.loadCalls) != 1 || fake.loadCalls[0] != "sess" {
|
||||
t.Fatalf("LoadLatest calls = %v, want exactly ['sess']", fake.loadCalls)
|
||||
}
|
||||
if res.Steps != 3 {
|
||||
t.Fatalf("Steps = %d, want 3 (resumed at step 3)", res.Steps)
|
||||
}
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != 2 || messageText(got[1]) != "keep going" {
|
||||
t.Fatalf("resumed request = %+v, want [system, 'keep going']", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_NotFound_Fresh asserts LoadLatest is still called on the fresh
|
||||
// path and that ErrNotFound yields a full restart from step 1 with the caller's
|
||||
// messages (no checkpoint to resurrect).
|
||||
func TestRun_Resume_NotFound_Fresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "hello"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
fake := &fakeCheckpointStore{latestErr: persist.ErrNotFound}
|
||||
l := New(Options{Agent: a, Checkpoint: fake, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fake.loadCalls) != 1 || fake.loadCalls[0] != "sess" {
|
||||
t.Fatalf("LoadLatest calls = %v, want exactly ['sess']", fake.loadCalls)
|
||||
}
|
||||
if res.Steps != 1 {
|
||||
t.Fatalf("Steps = %d, want 1 (fresh run)", res.Steps)
|
||||
}
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != 2 || messageText(got[1]) != "hi" {
|
||||
t.Fatalf("fresh request = %+v, want [system, 'hi']", got)
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,21 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// Seed the per-Run capacity accumulator at quiet so the state can progress
|
||||
// (quiet→awake→full) across a multi-step tool loop; a nil/disabled decider
|
||||
// leaves it unused. It is scoped to this Run, so concurrent runs never share.
|
||||
// The session id rides the same context so a capacity_awaken emission can be
|
||||
// targeted to the session that produced it (design/14 §5).
|
||||
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
|
||||
ctx = withLoopSession(ctx, sessionID)
|
||||
|
||||
msgs := l.resolveImages(ctx, messages)
|
||||
// Resume an interrupted run (design §1 durable execution): when the session
|
||||
// has a resumable checkpoint, rebuild its context and continue at the next
|
||||
// step rather than restarting from a fresh conversation. A different
|
||||
// sessionID — or a checkpoint left by a completed run — falls through to a
|
||||
// fresh start (LoadLatest misses, or the stop reason is not resumable).
|
||||
msgs, startStep, _ := l.loadCheckpoint(ctx, sessionID, messages)
|
||||
msgs = l.resolveImages(ctx, msgs)
|
||||
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
||||
res := &Result{}
|
||||
for step := 1; step <= l.maxRounds; step++ {
|
||||
for step := startStep; step <= l.maxRounds; step++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package loop
|
||||
|
||||
import "context"
|
||||
|
||||
// loopSessionIDKey is the context key for the session id the current Run is
|
||||
// driving. It is seeded at the top of Run and read where the loop emits a
|
||||
// session-scoped UI event (capacity_awaken), so the event carries the right
|
||||
// session rather than being treated as global.
|
||||
type loopSessionIDKey struct{}
|
||||
|
||||
// withLoopSession returns a context carrying the Run's session id.
|
||||
func withLoopSession(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, loopSessionIDKey{}, sessionID)
|
||||
}
|
||||
|
||||
// loopSession returns the session id on ctx, or "" when none was seeded (a
|
||||
// defensive fallback for a caller that drives the loop outside Run).
|
||||
func loopSession(ctx context.Context) string {
|
||||
s, _ := ctx.Value(loopSessionIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// auditToolRejectNotAllowed is the audit "what" for a tool call denied by the
|
||||
// design/11 §3 security gate in a group/stranger environment.
|
||||
const auditToolRejectNotAllowed = "tool.reject.not_allowed"
|
||||
|
||||
// AuditEvent reports a security-gated tool rejection to the audit sink
|
||||
// (design/11 §3 §8). The loop emits one when it denies a tool call in a
|
||||
// restricted (group/stranger) environment, so the rejection is auditable and the
|
||||
// frontend can render the small "xx 工具在群聊被拒" note. It is wired to the
|
||||
// gateway audit trail via Options.OnAudit.
|
||||
type AuditEvent struct {
|
||||
// Who is the acting actor environment label ("group_member"/"stranger").
|
||||
Who string
|
||||
// What is the operation, e.g. "tool.reject.not_allowed".
|
||||
What string
|
||||
// Result is the outcome detail — the not_allowed hint naming the denied tool.
|
||||
Result string
|
||||
}
|
||||
|
||||
// restrictSchemas returns only the tool schemas the session env on ctx may
|
||||
// invoke, per the design/11 §3 security gate: a group/stranger session may
|
||||
// invoke only tools whose editable marker allows that environment (default
|
||||
// closed), while the owner environment keeps the full set. It is the final word
|
||||
// over the capacity allowed-set view — a security red line, not a convenience
|
||||
// hint — so it runs last and can only shrink the surface.
|
||||
func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec {
|
||||
policy := d.PolicyEngine(sessionEnvFrom(ctx).ActorEnv)
|
||||
kept := make([]model.ToolSpec, 0, len(schemas))
|
||||
for _, s := range schemas {
|
||||
if policy.Allow(s.Name) {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// applyToolSecurityGate blocks every call whose tool is not allowed in the
|
||||
// session env on ctx (design/11 §3). It mutates actions in place, overriding
|
||||
// each runnable call to a skip carrying the not_allowed hint and emitting an
|
||||
// AuditEvent for the rejection. A nil receiver or nil dispatcher is a no-op.
|
||||
func (l *Loop) applyToolSecurityGate(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
|
||||
env := sessionEnvFrom(ctx)
|
||||
policy := d.PolicyEngine(env.ActorEnv)
|
||||
for i := range calls {
|
||||
if !actions[i].run {
|
||||
continue
|
||||
}
|
||||
if policy.Allow(calls[i].Name) {
|
||||
continue
|
||||
}
|
||||
reason := notAllowedReason(calls[i].Name)
|
||||
actions[i] = toolCallAction{run: false, reason: reason}
|
||||
l.emitAudit(AuditEvent{
|
||||
Who: string(env.ActorEnv),
|
||||
What: auditToolRejectNotAllowed,
|
||||
Result: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notAllowedReason is the soft hint fed back to the model when a restricted
|
||||
// (group/stranger) session invokes a tool whose editable marker denies that
|
||||
// environment (design/11 §3). It is a soft result — injected into the
|
||||
// conversation, never a panic or hard crash — so the model can reassess.
|
||||
func notAllowedReason(name string) string {
|
||||
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
|
||||
}
|
||||
|
||||
// emitAudit forwards an AuditEvent to the configured OnAudit sink. It is a no-op
|
||||
// when no sink is wired, so the security gate still works (soft not_allowed
|
||||
// result) in leaf tests that do not attach an audit sink.
|
||||
func (l *Loop) emitAudit(ev AuditEvent) {
|
||||
if l.onAudit == nil {
|
||||
return
|
||||
}
|
||||
l.onAudit(ev)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// newPolicyDispatcher builds a fresh registry + dispatcher and registers one
|
||||
// mock tool per name, returning both so a test can set the group/stranger
|
||||
// markers on the registry before exercising the design/11 §3 security gate.
|
||||
func newPolicyDispatcher(t *testing.T, names []string) (*registry.Registry, *tool.Dispatcher) {
|
||||
t.Helper()
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := d.Register(&mockTool{name: name}); err != nil {
|
||||
t.Fatalf("Register %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
return reg, d
|
||||
}
|
||||
|
||||
// expectToolNames asserts got carries exactly the tool names in want, any order.
|
||||
// The registry List iterates a map, so registration order is not preserved;
|
||||
// a set comparison keeps these assertions order-independent.
|
||||
func expectToolNames(t *testing.T, got []model.ToolSpec, want ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("tool schema count = %d, want %d (%v)", len(got), len(want), want)
|
||||
}
|
||||
wantSet := make(map[string]bool, len(want))
|
||||
for _, n := range want {
|
||||
wantSet[n] = true
|
||||
}
|
||||
for _, s := range got {
|
||||
if !wantSet[s.Name] {
|
||||
t.Fatalf("unexpected tool %q in schemas %v", s.Name, toolNames(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolSchemas_RestrictedEnv_FiltersByMarkers proves a group/stranger session
|
||||
// is only shown the tools whose editable marker allows that environment, while
|
||||
// the owner session keeps the full set (design/11 §3).
|
||||
func TestToolSchemas_RestrictedEnv_FiltersByMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg, d := newPolicyDispatcher(t, []string{"groupOnly", "strangerOnly", "both", "ownerOnly"})
|
||||
mustSetPolicy(t, reg, "groupOnly", registry.ToolPolicy{AllowInGroup: true})
|
||||
mustSetPolicy(t, reg, "strangerOnly", registry.ToolPolicy{AllowInStranger: true})
|
||||
mustSetPolicy(t, reg, "both", registry.ToolPolicy{AllowInGroup: true, AllowInStranger: true})
|
||||
// ownerOnly stays default-closed (zero marker).
|
||||
|
||||
l := New(Options{Tools: d})
|
||||
|
||||
group := l.toolSchemas(WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}), nil)
|
||||
expectToolNames(t, group, "groupOnly", "both")
|
||||
|
||||
stranger := l.toolSchemas(WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}), nil)
|
||||
expectToolNames(t, stranger, "strangerOnly", "both")
|
||||
|
||||
owner := l.toolSchemas(WithSessionEnv(context.Background(), OwnerEnv()), nil)
|
||||
expectToolNames(t, owner, "groupOnly", "strangerOnly", "both", "ownerOnly")
|
||||
}
|
||||
|
||||
// TestToolSchemas_DefaultClosed_UnmarkedToolHidden proves the defaulted-closed
|
||||
// marker: an unmarked tool is not advertised to a group/stranger session, so a
|
||||
// session cannot even see (let alone call) an owner-only capability.
|
||||
func TestToolSchemas_DefaultClosed_UnmarkedToolHidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg, d := newPolicyDispatcher(t, []string{"secret", "shared"})
|
||||
// Only shared is opened to groups; secret is default-closed.
|
||||
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
||||
l := New(Options{Tools: d})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
||||
expectToolNames(t, l.toolSchemas(ctx, nil), "shared")
|
||||
}
|
||||
|
||||
// TestRun_Restricted_NotAllowed_BlockedAndAudited proves a group session calling
|
||||
// an owner-only tool never executes it: the call is fed back as a soft
|
||||
// not_allowed result (no panic/crash) and an AuditEvent is recorded for the
|
||||
// rejection.
|
||||
func TestRun_Restricted_NotAllowed_BlockedAndAudited(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "done"}},
|
||||
}}
|
||||
reg, d := newPolicyDispatcher(t, []string{"ownerOnly", "shared"})
|
||||
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
||||
|
||||
a := newTestAgent(t, ad, d)
|
||||
var mu sync.Mutex
|
||||
var audited []AuditEvent
|
||||
l := New(Options{Agent: a, MaxRounds: 8, OnAudit: func(ev AuditEvent) {
|
||||
mu.Lock()
|
||||
audited = append(audited, ev)
|
||||
mu.Unlock()
|
||||
}})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "done" {
|
||||
t.Fatalf("Text = %q, want done", res.Text)
|
||||
}
|
||||
if res.ToolCallsExecuted != 0 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 0 (ownerOnly blocked in group)", res.ToolCallsExecuted)
|
||||
}
|
||||
|
||||
last := ad.reqs[len(ad.reqs)-1].Messages
|
||||
lastMsg := last[len(last)-1]
|
||||
if !strings.Contains(lastMsg.Content[0].Text, "not_allowed") ||
|
||||
!strings.Contains(lastMsg.Content[0].Text, "ownerOnly") {
|
||||
t.Fatalf("expected not_allowed soft result, got %q", lastMsg.Content[0].Text)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(audited) != 1 {
|
||||
t.Fatalf("audit events = %d, want 1", len(audited))
|
||||
}
|
||||
if audited[0].What != auditToolRejectNotAllowed {
|
||||
t.Fatalf("audit what = %q, want %q", audited[0].What, auditToolRejectNotAllowed)
|
||||
}
|
||||
if audited[0].Who != string(restrict.ActorGroupMember) {
|
||||
t.Fatalf("audit who = %q, want %q", audited[0].Who, restrict.ActorGroupMember)
|
||||
}
|
||||
if !strings.Contains(audited[0].Result, "ownerOnly") {
|
||||
t.Fatalf("audit result = %q, want to name ownerOnly", audited[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Restricted_AllowedTool_Executes proves a group session may invoke a
|
||||
// tool whose AllowInGroup marker is set, and that it runs normally.
|
||||
func TestRun_Restricted_AllowedTool_Executes(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := `{"tool_calls":[{"name":"shared","args":{"q":"hi"}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "done"}},
|
||||
}}
|
||||
reg, d := newPolicyDispatcher(t, []string{"shared", "ownerOnly"})
|
||||
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
||||
|
||||
a := newTestAgent(t, ad, d)
|
||||
l := New(Options{Agent: a, MaxRounds: 8})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1 (shared allowed in group)", res.ToolCallsExecuted)
|
||||
}
|
||||
if res.Text != "done" {
|
||||
t.Fatalf("Text = %q, want done", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Owner_AllToolsExecute proves the owner environment is unaffected by the
|
||||
// default-closed marker: the owner may invoke an owner-only (unmarked) tool.
|
||||
func TestRun_Owner_AllToolsExecute(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "done"}},
|
||||
}}
|
||||
_, d := newPolicyDispatcher(t, []string{"ownerOnly"})
|
||||
// Default-closed marker (never opened): owner must still be able to call it.
|
||||
a := newTestAgent(t, ad, d)
|
||||
l := New(Options{Agent: a, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(WithSessionEnv(context.Background(), OwnerEnv()), "sess",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "go")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1 (owner unaffected by default-closed)", res.ToolCallsExecuted)
|
||||
}
|
||||
if res.Text != "done" {
|
||||
t.Fatalf("Text = %q, want done", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Stranger_NotAllowed_Blocked proves the gate also applies to the
|
||||
// stranger private-chat environment, and that a group-only tool is denied there.
|
||||
func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := `{"tool_calls":[{"name":"groupOnly","args":{}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "done"}},
|
||||
}}
|
||||
reg, d := newPolicyDispatcher(t, []string{"groupOnly", "strangerOk"})
|
||||
mustSetPolicy(t, reg, "groupOnly", registry.ToolPolicy{AllowInGroup: true})
|
||||
mustSetPolicy(t, reg, "strangerOk", registry.ToolPolicy{AllowInStranger: true})
|
||||
|
||||
a := newTestAgent(t, ad, d)
|
||||
l := New(Options{Agent: a, MaxRounds: 8})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(),
|
||||
SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false})
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.ToolCallsExecuted != 0 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 0 (groupOnly denied to stranger)", res.ToolCallsExecuted)
|
||||
}
|
||||
last := ad.reqs[len(ad.reqs)-1].Messages
|
||||
if !strings.Contains(last[len(last)-1].Content[0].Text, "not_allowed") {
|
||||
t.Fatalf("expected not_allowed soft result, got %q", last[len(last)-1].Content[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// mustSetPolicy applies a ToolPolicy to the named tool, failing the test on error.
|
||||
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
|
||||
t.Helper()
|
||||
if err := reg.SetPolicy(name, pol); err != nil {
|
||||
t.Fatalf("SetPolicy %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,15 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
|
||||
res.terminate = true
|
||||
}
|
||||
|
||||
// Security gate (design/11 §3): a group/stranger session may invoke only
|
||||
// tools whose editable marker allows that environment (default closed). A
|
||||
// disallowed call never executes; it is fed back as a not_allowed soft result
|
||||
// and recorded to the audit sink. It is the final authority over the batch —
|
||||
// a later audit hook may tighten but never re-admit a denied tool.
|
||||
if d != nil {
|
||||
l.applyToolSecurityGate(ctx, d, calls, actions)
|
||||
}
|
||||
|
||||
// BatchAdmit hook (audit / political review): block the whole batch.
|
||||
if l.hooks.BatchAdmit != nil {
|
||||
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
|
||||
|
||||
Reference in new issue
Block a user