From 7e39153a6936b4064931a7d720d9ff2516da2282 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 13:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E8=90=BD=E5=B7=AE=20#1/#2/#3/#6=20+=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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拆文件后置(主人说后三不急) --- cmd/cyrene/wire.go | 41 ++- cmd/cyrene/wire_test.go | 63 +++++ frontend/web/src/admin/AdminApp.tsx | 92 ++++++- internal/runtime/capacity/capacity.go | 8 +- .../runtime/loop/capacity_l4_loop_test.go | 13 +- internal/runtime/loop/checkpoint.go | 70 +++++ internal/runtime/loop/loop.go | 5 +- internal/runtime/loop/options.go | 14 +- internal/runtime/loop/request.go | 12 +- internal/runtime/loop/resume_test.go | 194 ++++++++++++++ internal/runtime/loop/run.go | 13 +- internal/runtime/loop/session_ctx.go | 21 ++ internal/runtime/loop/tool_policy.go | 86 ++++++ .../runtime/loop/tool_policy_gating_test.go | 244 ++++++++++++++++++ internal/runtime/loop/tools.go | 9 + internal/runtime/model/anthropic.go | 60 ++++- internal/runtime/model/anthropic_stream.go | 2 +- internal/runtime/model/anthropic_test.go | 100 +++++++ internal/runtime/model/model.go | 53 +++- internal/runtime/model/openai.go | 109 +++++++- internal/runtime/model/openai_test.go | 77 ++++++ internal/runtime/tool/tool.go | 14 + internal/services/gateway/extra_test.go | 20 ++ internal/services/gateway/server.go | 22 ++ 24 files changed, 1286 insertions(+), 56 deletions(-) create mode 100644 cmd/cyrene/wire_test.go create mode 100644 internal/runtime/loop/resume_test.go create mode 100644 internal/runtime/loop/session_ctx.go create mode 100644 internal/runtime/loop/tool_policy.go create mode 100644 internal/runtime/loop/tool_policy_gating_test.go diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 5791e7d..72537d9 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -101,6 +101,16 @@ type app struct { gw *gateway.Server channelBridge *channelbridge.Bridge channels gateway.ChannelConfigStore + // capacitySink broadcasts capacity_awaken transitions to the Web UI (design/14 + // §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an + // interface so the composition root is unit-testable with a fake sink. + capacitySink capacityEmitter +} + +// capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts. +// *gateway.Server implements it; tests substitute a fake to capture the args. +type capacityEmitter interface { + BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) } // newApp assembles the full Cyrene runtime from cfg and log. It opens the @@ -330,6 +340,7 @@ func (a *app) wireRuntime() error { Tools: a.dispatcher, Capacity: capDecider, OnCapacity: a.onCapacity, + OnAudit: a.onToolAudit, Memory: store, Retriever: a.retriever, Extractor: memory.NewManualExtractor(personaID, "owner"), @@ -379,12 +390,33 @@ func (a *app) buildCapacity() *capacity.Decider { } // onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken -// transition for observability. A future frontend wire (design/14 §5) will render -// the "能力点亮" progression from these events. +// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` +// frame. The loop only emits on a real state transition (never every round), and +// the event carries the originating session id so it routes to that session, not +// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}. func (a *app) onCapacity(ev capacity.Event) { - a.log.Debug("capacity event", + logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", "type", ev.Type, "from", ev.FromState, "to", ev.ToState, - "tool", ev.Tool, "at", ev.At) + "tool", ev.Tool, "at", ev.At, "session", ev.SessionID) + if a.capacitySink == nil { + return + } + a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool) +} + +// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the +// design/11 §3 security gate in a group/stranger environment into the gateway +// audit trail, so the rejection is "audit 可查" and the frontend can render the +// small "xx 工具在群聊被拒" note (design/11 §8). The gateway is built after the +// loop (wireFrontend), so the sink reads a.gw at call time — by then the app is +// fully assembled and any Run is served through the gateway. +func (a *app) onToolAudit(ev loop.AuditEvent) { + if a.gw == nil { + logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready", + "who", ev.Who, "what", ev.What, "result", ev.Result) + return + } + a.gw.AuditEvent(ev.Who, ev.What, ev.Result) } // parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of @@ -606,6 +638,7 @@ func (a *app) wireFrontend() error { }) } a.gw = gw + a.capacitySink = gw return nil } diff --git a/cmd/cyrene/wire_test.go b/cmd/cyrene/wire_test.go new file mode 100644 index 0000000..b37d860 --- /dev/null +++ b/cmd/cyrene/wire_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" +) + +// fakeCapacityEmitter captures the capacity_awaken broadcast args so the +// composition-root test can assert the wire forwards the transition to the sink. +type fakeCapacityEmitter struct { + callCount int + sessionID string + fromState string + toState string + tool string +} + +func (f *fakeCapacityEmitter) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) { + f.callCount++ + f.sessionID = sessionID + f.fromState = fromState + f.toState = toState + f.tool = tool +} + +// TestOnCapacityBroadcastsAwaken exercises the audit-gap fix (#6): a capacity +// state transition received by the loop's OnCapacity sink is forwarded to the +// gateway as a capacity_awaken broadcast carrying the originating session and the +// frontend AwakenPayload fields (fromState, toState, tool). +func TestOnCapacityBroadcastsAwaken(t *testing.T) { + fake := &fakeCapacityEmitter{} + a := &app{log: testLog(), capacitySink: fake} + + a.onCapacity(capacity.Event{ + Type: capacity.EventAwaken, + FromState: capacity.StateQuiet, + ToState: capacity.StateAwake, + Tool: "web_search", + SessionID: "web", + }) + + if fake.callCount != 1 { + t.Fatalf("broadcast call count = %d, want 1", fake.callCount) + } + if fake.sessionID != "web" || fake.fromState != "quiet" || fake.toState != "awake" || fake.tool != "web_search" { + t.Fatalf("broadcast args = {sessionID:%q from:%q to:%q tool:%q}, want {web,quiet,awake,web_search}", + fake.sessionID, fake.fromState, fake.toState, fake.tool) + } +} + +// TestOnCapacityNilSinkNoPanic locks the guard: a boot/degraded path where the +// gateway is not yet wired must not panic — the sink is optional. +func TestOnCapacityNilSinkNoPanic(t *testing.T) { + a := &app{log: testLog()} + a.onCapacity(capacity.Event{ + Type: capacity.EventAwaken, + FromState: capacity.StateQuiet, + ToState: capacity.StateFull, + Tool: "code", + SessionID: "web", + }) +} diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 7f486e9..0128f92 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -42,16 +42,22 @@ function renderSection(section: AdminSectionId): React.ReactNode { } } -function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode { +/** + * Sidebar content shared by the desktop rail and the mobile drawer (design/10). + * Both render ADMIN_GROUPS with the same `onNavigate` hook — the drawer passes + * its close callback (tap an item → close), the desktop rail passes a no-op — so + * desktop and mobile never diverge. + */ +function adminNavContent({ active, onNavigate }: { active: AdminSectionId; onNavigate: () => void }): React.ReactNode { return ( -