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
+37
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
|
||||
<>
|
||||
{ADMIN_GROUPS.map((group) => (
|
||||
<div key={group} className="mb-1">
|
||||
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">{group}</div>
|
||||
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => navigateAdmin(s.id)}
|
||||
onClick={() => { navigateAdmin(s.id); onNavigate(); }}
|
||||
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
|
||||
s.id === active
|
||||
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||||
@@ -64,11 +70,50 @@ function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Desktop left rail — fixed, `hidden md:flex`. */
|
||||
function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
|
||||
return (
|
||||
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
|
||||
{adminNavContent({ active, onNavigate: () => {} })}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode {
|
||||
/** Mobile drawer — scrim + spring slide-in panel (reuses mu-drawer-in). */
|
||||
function AdminMobileNav({ open, active, onClose }: { open: boolean; active: AdminSectionId; onClose: () => void }): React.ReactNode {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭侧边栏"
|
||||
/>
|
||||
<nav
|
||||
className="glass absolute inset-y-0 left-0 flex w-[var(--mu-sidebar-w)] max-w-[80vw] flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3"
|
||||
style={{ animation: "mu-drawer-in 0.3s var(--mu-spring) both" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
<span className="text-xs font-medium text-[var(--mu-text-lo)]">菜单</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 hover:bg-[var(--mu-surface-4)] active:scale-95"
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{adminNavContent({ active, onNavigate: onClose })}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
|
||||
const role = getStoredRole();
|
||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
|
||||
@@ -79,24 +124,38 @@ function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold">Cyrene · 管理面板</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
|
||||
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-2 border-b border-[var(--mu-glass-border)] px-3 sm:px-4">
|
||||
{onMenu ? (
|
||||
<button
|
||||
onClick={onMenu}
|
||||
aria-label="打开菜单"
|
||||
title="菜单"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-95 md:hidden"
|
||||
>
|
||||
<Icon name="menu" size={20} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7 shrink-0" />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="truncate whitespace-nowrap text-sm font-semibold">Cyrene · 管理面板</div>
|
||||
<div className="truncate whitespace-nowrap text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{role ? <Tag tone={role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"}>{role}</Tag> : null}
|
||||
{role ? (
|
||||
<Tag tone={role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"} className="hidden shrink-0 sm:inline-flex">
|
||||
{role}
|
||||
</Tag>
|
||||
) : null}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="切换主题"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||||
>
|
||||
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
|
||||
</button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { window.location.hash = ""; }}>
|
||||
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => { window.location.hash = ""; }}>
|
||||
返回用户端
|
||||
</Button>
|
||||
</header>
|
||||
@@ -137,6 +196,10 @@ function Forbidden403(): React.ReactNode {
|
||||
}
|
||||
|
||||
export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode {
|
||||
// Mobile nav drawer — only surfaced on the admin shell (md+ keeps the fixed
|
||||
// sidebar); the login/403 guards never render it but still hold the hook.
|
||||
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
|
||||
|
||||
// RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an
|
||||
// unauthenticated user never sees the admin module; the gateway would also
|
||||
// reject them with 401/403.
|
||||
@@ -159,13 +222,14 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<AdminTopBar section={section} />
|
||||
<AdminTopBar section={section} onMenu={() => setAdminMenuOpen(true)} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<AdminSidebar active={section} />
|
||||
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
|
||||
</main>
|
||||
</div>
|
||||
<AdminMobileNav open={adminMenuOpen} active={section} onClose={() => setAdminMenuOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,13 @@ type Event struct {
|
||||
// Tool is the tool that this transition lit up (点亮工具). Empty when no
|
||||
// single tool can be named.
|
||||
Tool string
|
||||
At time.Time
|
||||
// At is the wall-clock time the transition was observed.
|
||||
At time.Time
|
||||
// SessionID is set by the runtime layer (loop) to the conversation session the
|
||||
// transition belongs to, so the frontend targets the correct session rather
|
||||
// than treating the event as global. It is empty when the emitter never ran
|
||||
// inside a session-scoped turn (e.g. a direct toolSchemas call in a unit test).
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// IsAwaken reports whether the event is a capacity_awaken transition.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -304,7 +304,7 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
blocks, err := encodeAnthropicContent(m.Content)
|
||||
blocks, err := encodeAnthropicMessage(m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -316,11 +316,13 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
||||
return sys.String(), out, nil
|
||||
}
|
||||
|
||||
// messageText concatenates the text parts of a message (used for system prompts).
|
||||
// messageText concatenates the text-bearing parts of a message (system prompts
|
||||
// and tool-result bodies). Tool-result parts carry their body in Text, so a
|
||||
// RoleTool turn's content serializes correctly for OpenAI's tool message.
|
||||
func messageText(m Message) string {
|
||||
var b strings.Builder
|
||||
for _, part := range m.Content {
|
||||
if part.Type == ContentTypeText {
|
||||
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
|
||||
b.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
@@ -340,8 +342,43 @@ func anthropicRole(r Role) string {
|
||||
}
|
||||
}
|
||||
|
||||
// encodeAnthropicMessage turns a normalized message into Anthropic content
|
||||
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
|
||||
// a user plain-text turn), because Anthropic requires a tool result to arrive as
|
||||
// a tool_result block carrying the tool_use_id and the is_error flag.
|
||||
func encodeAnthropicMessage(m Message) ([]any, error) {
|
||||
if m.Role == RoleTool {
|
||||
return encodeAnthropicToolResult(m)
|
||||
}
|
||||
return encodeAnthropicContent(m.Content)
|
||||
}
|
||||
|
||||
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
|
||||
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
|
||||
// tool_result blocks too so the turn never degrades into a plain user message.
|
||||
// The tool_use_id falls back to the message-level ToolCallID when a part does not
|
||||
// carry its own.
|
||||
func encodeAnthropicToolResult(m Message) ([]any, error) {
|
||||
blocks := make([]any, 0, len(m.Content))
|
||||
for _, p := range m.Content {
|
||||
switch p.Type {
|
||||
case ContentTypeToolResult, ContentTypeText:
|
||||
id := p.ToolCallID
|
||||
if id == "" {
|
||||
id = m.ToolCallID
|
||||
}
|
||||
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// encodeAnthropicContent turns normalized content parts into Anthropic content
|
||||
// blocks.
|
||||
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
|
||||
// parts are rendered at the message level by encodeAnthropicToolResult.
|
||||
func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
blocks := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
@@ -361,6 +398,19 @@ func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
case ContentTypeToolCall:
|
||||
args := p.ToolArgs
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": p.ToolCallID,
|
||||
"name": p.ToolCallName,
|
||||
"input": args,
|
||||
})
|
||||
case ContentTypeToolResult:
|
||||
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
@@ -421,7 +471,7 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
||||
case "text":
|
||||
b.WriteString(blk.Text)
|
||||
case "tool_use":
|
||||
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
|
||||
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, ID: blk.ID, Args: blk.Input})
|
||||
}
|
||||
}
|
||||
out.Content = b.String()
|
||||
|
||||
@@ -232,6 +232,6 @@ func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order [
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, Args: args})
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, ID: b.id, Args: args})
|
||||
}
|
||||
}
|
||||
@@ -509,3 +509,103 @@ func TestAnthropicChat_ToolResult(t *testing.T) {
|
||||
t.Errorf("is_error = %v, want true", blk["is_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// anthropicBlockContent asserts a message renders a single Anthropic content block
|
||||
// and returns it as a map for inspection.
|
||||
func anthropicBlockContent(t *testing.T, msg anthropicMessage) (map[string]any, bool) {
|
||||
t.Helper()
|
||||
blocks, ok := msg.Content.([]any)
|
||||
if !ok || len(blocks) != 1 {
|
||||
t.Fatalf("content = %#v, want exactly 1 content block", msg.Content)
|
||||
}
|
||||
block, ok := blocks[0].(map[string]any)
|
||||
return block, ok
|
||||
}
|
||||
|
||||
func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []Message{
|
||||
TextMessage(RoleUser, "what's the weather in Paris?"),
|
||||
AssistantToolCallMessage([]ToolCall{{ID: "toolu_1", Name: "weather", Args: map[string]any{"city": "Paris"}}}),
|
||||
ToolResultMessage("toolu_1", `{"temp":22}`, true),
|
||||
}
|
||||
sys, out, err := buildAnthropicMessages(msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAnthropicMessages error: %v", err)
|
||||
}
|
||||
if sys != "" {
|
||||
t.Errorf("system = %q, want empty", sys)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("messages = %d, want 3", len(out))
|
||||
}
|
||||
|
||||
// The assistant turn must carry the native tool_use block, not plain text.
|
||||
if out[1].Role != "assistant" {
|
||||
t.Errorf("assistant role = %q, want assistant", out[1].Role)
|
||||
}
|
||||
use, ok := anthropicBlockContent(t, out[1])
|
||||
if !ok {
|
||||
t.Fatal("assistant content is not a single block")
|
||||
}
|
||||
if use["type"] != "tool_use" || use["id"] != "toolu_1" || use["name"] != "weather" {
|
||||
t.Errorf("tool_use block = %#v, want type=tool_use id=toolu_1 name=weather", use)
|
||||
}
|
||||
if inp, _ := use["input"].(map[string]any); inp["city"] != "Paris" {
|
||||
t.Errorf("tool_use input = %#v, want {city:Paris}", use["input"])
|
||||
}
|
||||
|
||||
// The tool-result turn must be a user turn with a native tool_result block
|
||||
// (the audit's core fix: never a user plain-text turn).
|
||||
if out[2].Role != "user" {
|
||||
t.Errorf("tool-result role = %q, want user", out[2].Role)
|
||||
}
|
||||
tr, ok := anthropicBlockContent(t, out[2])
|
||||
if !ok {
|
||||
t.Fatal("tool-result content is not a single block")
|
||||
}
|
||||
if tr["type"] != "tool_result" {
|
||||
t.Errorf("type = %v, want tool_result (not plain user text)", tr["type"])
|
||||
}
|
||||
if tr["tool_use_id"] != "toolu_1" {
|
||||
t.Errorf("tool_use_id = %v, want toolu_1", tr["tool_use_id"])
|
||||
}
|
||||
if tr["content"] != `{"temp":22}` {
|
||||
t.Errorf("content = %v, want the raw result", tr["content"])
|
||||
}
|
||||
if tr["is_error"] != true {
|
||||
t.Errorf("is_error = %v, want true", tr["is_error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicChat_Build_ToolResultLegacyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The loop's current form: a RoleTool message carrying a plain text part and
|
||||
// Message.ToolCallID. The protocol layer must still render a native tool_result
|
||||
// block (not a user plain-text turn).
|
||||
m := Message{Role: RoleTool, ToolCallID: "toolu_9",
|
||||
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
|
||||
_, out, err := buildAnthropicMessages([]Message{m})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAnthropicMessages error: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Role != "user" {
|
||||
t.Errorf("role = %q, want user", out[0].Role)
|
||||
}
|
||||
tr, ok := anthropicBlockContent(t, out[0])
|
||||
if !ok {
|
||||
t.Fatal("content is not a single block")
|
||||
}
|
||||
if tr["type"] != "tool_result" {
|
||||
t.Errorf("type = %v, want tool_result (not user text)", tr["type"])
|
||||
}
|
||||
if tr["tool_use_id"] != "toolu_9" {
|
||||
t.Errorf("tool_use_id = %v, want toolu_9", tr["tool_use_id"])
|
||||
}
|
||||
if tr["content"] != "tool weather result: 22" {
|
||||
t.Errorf("content = %v, want the text", tr["content"])
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,36 @@ const (
|
||||
ContentTypeText ContentType = "text"
|
||||
// ContentTypeImage is an image content part.
|
||||
ContentTypeImage ContentType = "image_url"
|
||||
// ContentTypeToolCall is an assistant-originated tool invocation part
|
||||
// (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn.
|
||||
ContentTypeToolCall ContentType = "tool_call"
|
||||
// ContentTypeToolResult is a tool execution result part, carried on a RoleTool
|
||||
// turn and encoded natively (Anthropic tool_result / OpenAI tool message).
|
||||
ContentTypeToolResult ContentType = "tool_result"
|
||||
)
|
||||
|
||||
// Content is a single, possibly multimodal, part of a message.
|
||||
type Content struct {
|
||||
// Type distinguishes a text part from an image part.
|
||||
// Type distinguishes a text part from an image / tool part.
|
||||
Type ContentType
|
||||
// Text is the text payload when Type == ContentTypeText.
|
||||
// Text is the text payload when Type == ContentTypeText, or the result body
|
||||
// when Type == ContentTypeToolResult.
|
||||
Text string
|
||||
// ImageURL is the image source when Type == ContentTypeImage. It may be a
|
||||
// data: URI or an http(s):// URL.
|
||||
ImageURL string
|
||||
// ToolCallID correlates the part to a provider tool_use/tool_call id: the call
|
||||
// id for ContentTypeToolCall, or the tool_use_id being answered for
|
||||
// ContentTypeToolResult.
|
||||
ToolCallID string
|
||||
// ToolCallName is the invoked tool name when Type == ContentTypeToolCall.
|
||||
ToolCallName string
|
||||
// ToolArgs is the structured argument object when Type == ContentTypeToolCall.
|
||||
// It is never a JSON-encoded string.
|
||||
ToolArgs map[string]any
|
||||
// IsError marks a ContentTypeToolResult part as an execution failure so the
|
||||
// provider's tool_result.is_error / error flag is set natively.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// Message is a single conversation turn, normalized across providers.
|
||||
@@ -62,8 +81,11 @@ type Message struct {
|
||||
// channel layer. Empty means the sender is unlabeled (the owner, or an
|
||||
// unknown/unresolved sender), and callers may leave it unset.
|
||||
SenderName string
|
||||
// ToolCallID and ToolCallName are reserved as interface-level placeholders
|
||||
// for the M2 tool loop; they are ignored by current adapters.
|
||||
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
|
||||
// it answers, and ToolCallName is the invoked tool name carried as a
|
||||
// convenience for callers that render the result. Both are set by the tool
|
||||
// loop when it feeds a result back, and consumed by the adapters to emit
|
||||
// native tool_result / tool-message blocks.
|
||||
ToolCallID string
|
||||
ToolCallName string
|
||||
}
|
||||
@@ -84,6 +106,25 @@ func ImageMessage(role Role, text, imageURL string) Message {
|
||||
return Message{Role: role, Content: parts}
|
||||
}
|
||||
|
||||
// ToolResultMessage builds a RoleTool turn carrying the native result of one tool
|
||||
// call. toolUseID must equal the provider id of the tool_use block this result
|
||||
// answers; isError marks an execution failure; text is the result body.
|
||||
func ToolResultMessage(toolUseID, text string, isError bool) Message {
|
||||
return Message{Role: RoleTool, ToolCallID: toolUseID,
|
||||
Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}}
|
||||
}
|
||||
|
||||
// AssistantToolCallMessage builds a RoleAssistant turn advertising the tool calls
|
||||
// the model requested, so a subsequent RoleTool turn can be correlated to them by
|
||||
// the provider (OpenAI tool_call_id / Anthropic tool_use_id).
|
||||
func AssistantToolCallMessage(calls []ToolCall) Message {
|
||||
parts := make([]Content, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
parts = append(parts, Content{Type: ContentTypeToolCall, ToolCallID: c.ID, ToolCallName: c.Name, ToolArgs: c.Args})
|
||||
}
|
||||
return Message{Role: RoleAssistant, Content: parts}
|
||||
}
|
||||
|
||||
// ToolSpec is an interface-level placeholder for a tool/function schema. The
|
||||
// full tool loop (registration, argument binding, result re-feed) is M2; here
|
||||
// it only lets a request advertise tools to the provider in the wire contract.
|
||||
@@ -99,6 +140,10 @@ type ToolSpec struct {
|
||||
// (never a serialized JSON string), so a tool loop can bind it directly without
|
||||
// re-parsing.
|
||||
type ToolCall struct {
|
||||
// ID is the provider tool_use/tool_call id, so a tool loop can correlate the
|
||||
// resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI
|
||||
// tool_call_id). Empty when the provider emitted no id.
|
||||
ID string
|
||||
// Name is the tool being invoked.
|
||||
Name string
|
||||
// Args are the tool arguments, decoded from the provider's structured
|
||||
|
||||
@@ -48,10 +48,23 @@ type openAIStreamOpts struct {
|
||||
}
|
||||
|
||||
// openAIMessage is one request message in the wire body. Content is either a
|
||||
// plain string (text-only) or an array of {type,text|image_url} parts.
|
||||
// plain string (text-only) or an array of {type,text|image_url} parts. Tool
|
||||
// messages carry ToolCallID (required by OpenAI); assistant messages that
|
||||
// requested functions carry ToolCalls so the following tool messages can be
|
||||
// correlated.
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI
|
||||
// requires each subsequent tool message to reference the id back to these calls.
|
||||
type openAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function openAIToolCallFn `json:"function"`
|
||||
}
|
||||
|
||||
// openAITool is the wire form of a function tool schema (M2 placeholder).
|
||||
@@ -86,8 +99,9 @@ type openAIChoice struct {
|
||||
|
||||
// openAIResponseMessage is the assistant message inside a non-streaming choice.
|
||||
type openAIResponseMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// openAIDelta is the incremental payload inside a stream chunk. Reasoning content
|
||||
@@ -320,22 +334,75 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
|
||||
func buildMessages(msgs []Message) ([]openAIMessage, error) {
|
||||
out := make([]openAIMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
content, err := encodeContent(m.Content)
|
||||
wire, err := encodeOpenAIMessage(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, openAIMessage{Role: string(m.Role), Content: content})
|
||||
out = append(out, wire)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// encodeOpenAIMessage renders one normalized message in OpenAI wire form. A
|
||||
// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI,
|
||||
// and the audit's gap); an assistant turn that advertised tool calls carries the
|
||||
// corresponding tool_calls array so the provider can correlate the tool messages.
|
||||
func encodeOpenAIMessage(m Message) (openAIMessage, error) {
|
||||
if m.Role == RoleTool {
|
||||
return openAIMessage{
|
||||
Role: string(RoleTool),
|
||||
ToolCallID: toolMessageCallID(m),
|
||||
Content: messageText(m),
|
||||
}, nil
|
||||
}
|
||||
content, err := encodeContent(m.Content)
|
||||
if err != nil {
|
||||
return openAIMessage{}, err
|
||||
}
|
||||
return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil
|
||||
}
|
||||
|
||||
// toolMessageCallID resolves the tool_call_id for a RoleTool turn: the
|
||||
// message-level id, else the id carried on the first tool-result part.
|
||||
func toolMessageCallID(m Message) string {
|
||||
if m.ToolCallID != "" {
|
||||
return m.ToolCallID
|
||||
}
|
||||
for _, p := range m.Content {
|
||||
if p.ToolCallID != "" {
|
||||
return p.ToolCallID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call
|
||||
// parts. Arguments are serialized to JSON because OpenAI expects a string.
|
||||
func encodeToolCalls(parts []Content) []openAIToolCall {
|
||||
calls := make([]openAIToolCall, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p.Type != ContentTypeToolCall {
|
||||
continue
|
||||
}
|
||||
arguments := "{}"
|
||||
if len(p.ToolArgs) > 0 {
|
||||
if b, err := json.Marshal(p.ToolArgs); err == nil {
|
||||
arguments = string(b)
|
||||
}
|
||||
}
|
||||
calls = append(calls, openAIToolCall{
|
||||
ID: p.ToolCallID,
|
||||
Type: "function",
|
||||
Function: openAIToolCallFn{Name: p.ToolCallName, Arguments: arguments},
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func encodeContent(parts []Content) (any, error) {
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(parts) == 1 && parts[0].Type == ContentTypeText {
|
||||
return parts[0].Text, nil
|
||||
}
|
||||
arr := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
@@ -346,10 +413,23 @@ func encodeContent(parts []Content) (any, error) {
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": p.ImageURL},
|
||||
})
|
||||
case ContentTypeToolCall, ContentTypeToolResult:
|
||||
// Tool-call and tool-result parts are encoded at the message level
|
||||
// (tool_calls array / tool_call_id), never as text-image content.
|
||||
continue
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(arr) == 1 {
|
||||
if blk, ok := arr[0].(map[string]any); ok && blk["type"] == "image_url" {
|
||||
return arr, nil
|
||||
}
|
||||
return arr[0].(map[string]any)["text"], nil
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
@@ -408,6 +488,13 @@ func responseFromWire(wire *openAIChatResponse) Response {
|
||||
text := wire.Choices[0].Message.Content
|
||||
out.Content = text
|
||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: text}}}
|
||||
for _, tc := range wire.Choices[0].Message.ToolCalls {
|
||||
args := map[string]any{}
|
||||
if tc.Function.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
out.ToolCalls = append(out.ToolCalls, ToolCall{Name: tc.Function.Name, ID: tc.ID, Args: args})
|
||||
}
|
||||
}
|
||||
if u := wire.Usage; u != nil {
|
||||
out.PromptTokens = u.PromptTokens
|
||||
@@ -553,7 +640,7 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: acc.name, Args: args})
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: acc.name, ID: acc.id, Args: args})
|
||||
}
|
||||
agg.Content = text.String()
|
||||
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: agg.Content}}}
|
||||
|
||||
@@ -352,3 +352,80 @@ func TestOpenAIChat_ChatStream_ToolCall(t *testing.T) {
|
||||
t.Errorf("events = %+v, want %+v", events, wantEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||||
|
||||
req := Request{Messages: []Message{
|
||||
TextMessage(RoleUser, "what's the weather in Paris?"),
|
||||
AssistantToolCallMessage([]ToolCall{{ID: "call_1", Name: "weather", Args: map[string]any{"city": "Paris"}}}),
|
||||
ToolResultMessage("call_1", `{"temp":22}`, true),
|
||||
}, Model: "gpt-test"}
|
||||
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
|
||||
got := <-ch
|
||||
msgs, _ := got.body["messages"].([]any)
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"])
|
||||
}
|
||||
|
||||
// The assistant turn must carry tool_calls (so the tool message can be
|
||||
// correlated by the provider) — this was previously dropped entirely.
|
||||
assistant, _ := msgs[1].(map[string]any)
|
||||
if assistant["role"] != "assistant" {
|
||||
t.Errorf("assistant role = %v, want assistant", assistant["role"])
|
||||
}
|
||||
tcs, _ := assistant["tool_calls"].([]any)
|
||||
if len(tcs) != 1 {
|
||||
t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"])
|
||||
}
|
||||
tc := tcs[0].(map[string]any)
|
||||
if tc["id"] != "call_1" || tc["type"] != "function" {
|
||||
t.Errorf("tool_call = %v, want id=call_1 type=function", tc)
|
||||
}
|
||||
fn, _ := tc["function"].(map[string]any)
|
||||
if fn["name"] != "weather" || fn["arguments"] != `{"city":"Paris"}` {
|
||||
t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn)
|
||||
}
|
||||
|
||||
// The tool message must carry tool_call_id (the audit's gap) and the result.
|
||||
tool, _ := msgs[2].(map[string]any)
|
||||
if tool["role"] != "tool" {
|
||||
t.Errorf("tool role = %v, want tool", tool["role"])
|
||||
}
|
||||
if tool["tool_call_id"] != "call_1" {
|
||||
t.Errorf("tool_call_id = %v, want call_1", tool["tool_call_id"])
|
||||
}
|
||||
if tool["content"] != `{"temp":22}` {
|
||||
t.Errorf("tool content = %v, want the raw result", tool["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The loop's current form: a RoleTool message carrying a plain text part and
|
||||
// Message.ToolCallID. buildMessages must emit a tool message with tool_call_id.
|
||||
m := Message{Role: RoleTool, ToolCallID: "call_9",
|
||||
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
|
||||
msgs, err := buildMessages([]Message{m})
|
||||
if err != nil {
|
||||
t.Fatalf("buildMessages error: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "tool" {
|
||||
t.Errorf("role = %q, want tool", msgs[0].Role)
|
||||
}
|
||||
if msgs[0].ToolCallID != "call_9" {
|
||||
t.Errorf("tool_call_id = %q, want call_9", msgs[0].ToolCallID)
|
||||
}
|
||||
if msgs[0].Content != "tool weather result: 22" {
|
||||
t.Errorf("content = %v, want the text", msgs[0].Content)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
@@ -187,6 +188,19 @@ func (d *Dispatcher) IsSequential(name string) bool {
|
||||
return d.modes[name] == ExecutionModeSequential
|
||||
}
|
||||
|
||||
// PolicyEngine returns a restrict.Policy scoped to env, reading the editable
|
||||
// reachability markers from the underlying capability registry (design/11 §3).
|
||||
// It is the loop's authoritative source for the group/stranger tool gate and is
|
||||
// fail-closed: a nil receiver or nil registry denies every tool in a non-owner
|
||||
// environment, and the owner environment always allows. The returned policy is
|
||||
// immutable and safe for concurrent use.
|
||||
func (d *Dispatcher) PolicyEngine(env restrict.ActorEnv) *restrict.Policy {
|
||||
if d == nil || d.reg == nil {
|
||||
return restrict.SetContext(nil, env)
|
||||
}
|
||||
return restrict.SetContext(d.reg, env)
|
||||
}
|
||||
|
||||
// Invoke executes the tool registered as name with the given args. It resolves
|
||||
// the tool from the registry, validates args against the tool's Parameters JSON
|
||||
// Schema, and runs Execute under the tool's deadline (per-tool override, else
|
||||
|
||||
@@ -859,6 +859,26 @@ func TestHubBroadcastStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerBroadcastCapacityAwaken(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
c := newFakeConn(env.srv.hub, 32)
|
||||
env.srv.hub.register(c)
|
||||
|
||||
env.srv.BroadcastCapacityAwaken("web", "quiet", "awake", "web_search")
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "capacity_awaken" {
|
||||
t.Fatalf("frame type = %q, want capacity_awaken (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
if ev.SessionID != "web" {
|
||||
t.Fatalf("frame sessionId = %q, want web", ev.SessionID)
|
||||
}
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok || data["fromState"] != "quiet" || data["toState"] != "awake" || data["tool"] != "web_search" {
|
||||
t.Fatalf("frame data = %+v, want {fromState:quiet,toState:awake,tool:web_search}", ev.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubStreamChatIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(nil, nil) // nil runner
|
||||
|
||||
@@ -508,6 +508,28 @@ func (s *Server) BroadcastPersisted(msg history.Message) {
|
||||
s.hub.broadcast(wsEvent{Type: "message_persisted", SessionID: msg.SessionID, Data: map[string]any{"message": wire}})
|
||||
}
|
||||
|
||||
// wsCapacityAwaken is the WS event type for a capacity state transition
|
||||
// (design/14 §5). Its data field is the frontend AwakenPayload {fromState,toState,tool}.
|
||||
const wsCapacityAwaken = "capacity_awaken"
|
||||
|
||||
// BroadcastCapacityAwaken fans a capacity state transition to the Web UI as a
|
||||
// `capacity_awaken` frame (design/14 §5). sessionID is the conversation that
|
||||
// transitioned, so the client targets the correct session rather than treating the
|
||||
// wake event as global. data matches the frontend AwakenPayload {fromState, toState,
|
||||
// tool}; tool may be empty (no single tool was lit, the client then keeps the prior
|
||||
// tool badge).
|
||||
func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) {
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsCapacityAwaken,
|
||||
SessionID: sessionID,
|
||||
Data: map[string]any{
|
||||
"fromState": fromState,
|
||||
"toState": toState,
|
||||
"tool": tool,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
|
||||
Reference in new issue
Block a user