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
+1281
-51
No files matched your search
+37
-4
@@ -101,6 +101,16 @@ type app struct {
|
|||||||
gw *gateway.Server
|
gw *gateway.Server
|
||||||
channelBridge *channelbridge.Bridge
|
channelBridge *channelbridge.Bridge
|
||||||
channels gateway.ChannelConfigStore
|
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
|
// 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,
|
Tools: a.dispatcher,
|
||||||
Capacity: capDecider,
|
Capacity: capDecider,
|
||||||
OnCapacity: a.onCapacity,
|
OnCapacity: a.onCapacity,
|
||||||
|
OnAudit: a.onToolAudit,
|
||||||
Memory: store,
|
Memory: store,
|
||||||
Retriever: a.retriever,
|
Retriever: a.retriever,
|
||||||
Extractor: memory.NewManualExtractor(personaID, "owner"),
|
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
|
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
|
||||||
// transition for observability. A future frontend wire (design/14 §5) will render
|
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
|
||||||
// the "能力点亮" progression from these events.
|
// 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) {
|
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,
|
"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
|
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
|
||||||
@@ -606,6 +638,7 @@ func (a *app) wireFrontend() error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
a.gw = gw
|
a.gw = gw
|
||||||
|
a.capacitySink = gw
|
||||||
return nil
|
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 (
|
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) => (
|
{ADMIN_GROUPS.map((group) => (
|
||||||
<div key={group} className="mb-1">
|
<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>
|
<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) => (
|
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
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] ${
|
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
|
s.id === active
|
||||||
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||||||
@@ -64,11 +70,50 @@ function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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 role = getStoredRole();
|
||||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||||
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
|
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
|
||||||
@@ -79,24 +124,38 @@ function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<div className="flex items-center gap-2">
|
{onMenu ? (
|
||||||
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
|
<button
|
||||||
<div className="leading-tight">
|
onClick={onMenu}
|
||||||
<div className="text-sm font-semibold">Cyrene · 管理面板</div>
|
aria-label="打开菜单"
|
||||||
<div className="text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
|
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>
|
</div>
|
||||||
<div className="flex-1" />
|
<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
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label="切换主题"
|
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} />
|
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
|
||||||
</button>
|
</button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => { window.location.hash = ""; }}>
|
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => { window.location.hash = ""; }}>
|
||||||
返回用户端
|
返回用户端
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
@@ -137,6 +196,10 @@ function Forbidden403(): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminApp({ section }: { section: AdminSectionId }): 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
|
// 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
|
// unauthenticated user never sees the admin module; the gateway would also
|
||||||
// reject them with 401/403.
|
// reject them with 401/403.
|
||||||
@@ -159,13 +222,14 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<AdminTopBar section={section} />
|
<AdminTopBar section={section} onMenu={() => setAdminMenuOpen(true)} />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<AdminSidebar active={section} />
|
<AdminSidebar active={section} />
|
||||||
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
|
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
|
||||||
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
|
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<AdminMobileNav open={adminMenuOpen} active={section} onClose={() => setAdminMenuOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,13 @@ type Event struct {
|
|||||||
// Tool is the tool that this transition lit up (点亮工具). Empty when no
|
// Tool is the tool that this transition lit up (点亮工具). Empty when no
|
||||||
// single tool can be named.
|
// single tool can be named.
|
||||||
Tool string
|
Tool string
|
||||||
|
// At is the wall-clock time the transition was observed.
|
||||||
At time.Time
|
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.
|
// 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")
|
t.Fatal("no streaming request captured")
|
||||||
}
|
}
|
||||||
set := toolSet(ad.streamReqs[0].Tools)
|
set := toolSet(ad.streamReqs[0].Tools)
|
||||||
// Restricted env → L4 silent → undecided turn stays quiet → only quiet tools, no
|
// Restricted env → L4 silent → the turn stays undecided, and the design/11 §3
|
||||||
// web-search, no owner habit read.
|
// security gate hides every tool that is not marked AllowInGroup (terminal/file
|
||||||
if !hasAll(set, "terminal", "file") {
|
// are unmarked). So the model sees nothing: web-search is absent (owner habit
|
||||||
t.Fatalf("restricted tools = %v, want quiet set terminal+file", toolNames(ad.streamReqs[0].Tools))
|
// not read) and even the quiet-set tools are closed to a group session.
|
||||||
}
|
if len(set) != 0 {
|
||||||
if set["web-search"] {
|
t.Fatalf("restricted tools = %v, want none (security gate closes unmarked tools)", toolNames(ad.streamReqs[0].Tools))
|
||||||
t.Fatalf("restricted tools = %v, must NOT wake (owner habit not read)", toolNames(ad.streamReqs[0].Tools))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,26 @@ package loop
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"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/model"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
"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
|
// 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
|
// message window and the step index lets a long-running task be resumed from the
|
||||||
// latest checkpoint without losing context (design §1 durable execution).
|
// 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)
|
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/guardrail"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"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/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/tool"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
)
|
)
|
||||||
@@ -89,7 +88,7 @@ type Loop struct {
|
|||||||
retriever *memory.Retriever
|
retriever *memory.Retriever
|
||||||
working *memory.WorkingMemory
|
working *memory.WorkingMemory
|
||||||
extractor memory.Extractor
|
extractor memory.Extractor
|
||||||
checkpoint *persist.Store
|
checkpoint checkpointStore
|
||||||
tracer *trace.Tracer
|
tracer *trace.Tracer
|
||||||
guards *guardrail.Pipeline
|
guards *guardrail.Pipeline
|
||||||
personaID string
|
personaID string
|
||||||
@@ -100,6 +99,7 @@ type Loop struct {
|
|||||||
toolParallelism int
|
toolParallelism int
|
||||||
hooks ToolLoopHooks
|
hooks ToolLoopHooks
|
||||||
onStream func(model.StreamEvent)
|
onStream func(model.StreamEvent)
|
||||||
|
onAudit func(AuditEvent)
|
||||||
maxContextTokens int
|
maxContextTokens int
|
||||||
compactor Compactor
|
compactor Compactor
|
||||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||||
@@ -150,6 +150,7 @@ func New(opts Options) *Loop {
|
|||||||
toolParallelism: toolParallelism,
|
toolParallelism: toolParallelism,
|
||||||
hooks: opts.Hooks,
|
hooks: opts.Hooks,
|
||||||
onStream: opts.OnStream,
|
onStream: opts.OnStream,
|
||||||
|
onAudit: opts.OnAudit,
|
||||||
maxContextTokens: opts.MaxContextTokens,
|
maxContextTokens: opts.MaxContextTokens,
|
||||||
compactor: compactor,
|
compactor: compactor,
|
||||||
transformContext: opts.TransformContext,
|
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/guardrail"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"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/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/tool"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
"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
|
// loop observes while deciding a round (design/14 §4), so a caller can
|
||||||
// surface the "能力点亮" progression to the frontend. Nil = no-op.
|
// surface the "能力点亮" progression to the frontend. Nil = no-op.
|
||||||
OnCapacity func(capacity.Event)
|
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 is the long-term memory store used to persist extracted items.
|
||||||
Memory *memory.Store
|
Memory *memory.Store
|
||||||
// Retriever injects relevant memories into the context, re-evaluated every
|
// Retriever injects relevant memories into the context, re-evaluated every
|
||||||
@@ -48,8 +53,11 @@ type Options struct {
|
|||||||
WorkingMemory *memory.WorkingMemory
|
WorkingMemory *memory.WorkingMemory
|
||||||
// Extractor derives memory items to persist after every step.
|
// Extractor derives memory items to persist after every step.
|
||||||
Extractor memory.Extractor
|
Extractor memory.Extractor
|
||||||
// Checkpoint persists a durable snapshot of the loop state at every step.
|
// Checkpoint persists a durable snapshot of the loop state at every step and,
|
||||||
Checkpoint *persist.Store
|
// 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 emits a structured span for every step, LLM call and tool call.
|
||||||
Tracer *trace.Tracer
|
Tracer *trace.Tracer
|
||||||
// Guards validates request/response payloads on the streaming path, where
|
// 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
|
// (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
|
// the allowed tools, while Invoke itself stays unblocked. A nil or disabled
|
||||||
// decider keeps the pre-existing behavior (every tool advertised).
|
// 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 {
|
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
|
||||||
d := l.dispatcher()
|
d := l.dispatcher()
|
||||||
if d == nil {
|
if d == nil {
|
||||||
@@ -65,7 +71,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
|||||||
}
|
}
|
||||||
schemas := d.ToolSchemas()
|
schemas := d.ToolSchemas()
|
||||||
if l.capacity == nil || !l.capacity.Enabled() {
|
if l.capacity == nil || !l.capacity.Enabled() {
|
||||||
return schemas
|
return l.restrictSchemas(ctx, d, schemas)
|
||||||
}
|
}
|
||||||
|
|
||||||
names := make([]string, 0, len(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
|
acc.state = dec.State
|
||||||
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
|
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
|
||||||
|
sessionID := loopSession(ctx)
|
||||||
for _, ev := range dec.Events {
|
for _, ev := range dec.Events {
|
||||||
|
ev.SessionID = sessionID
|
||||||
l.onCapacity(ev)
|
l.onCapacity(ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +104,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
|||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return l.restrictSchemas(ctx, d, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// capacityTexts extracts the recent conversation text for the capacity decider:
|
// 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
|
// 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
|
// (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.
|
// 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 = 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)}
|
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
||||||
res := &Result{}
|
res := &Result{}
|
||||||
for step := 1; step <= l.maxRounds; step++ {
|
for step := startStep; step <= l.maxRounds; step++ {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
|
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
|
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.
|
// BatchAdmit hook (audit / political review): block the whole batch.
|
||||||
if l.hooks.BatchAdmit != nil {
|
if l.hooks.BatchAdmit != nil {
|
||||||
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
|
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
blocks, err := encodeAnthropicContent(m.Content)
|
blocks, err := encodeAnthropicMessage(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
@@ -316,11 +316,13 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
|||||||
return sys.String(), out, nil
|
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 {
|
func messageText(m Message) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, part := range m.Content {
|
for _, part := range m.Content {
|
||||||
if part.Type == ContentTypeText {
|
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
|
||||||
b.WriteString(part.Text)
|
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
|
// 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) {
|
func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||||
blocks := make([]any, 0, len(parts))
|
blocks := make([]any, 0, len(parts))
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
@@ -361,6 +398,19 @@ func encodeAnthropicContent(parts []Content) ([]any, error) {
|
|||||||
"data": data,
|
"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:
|
default:
|
||||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||||
}
|
}
|
||||||
@@ -421,7 +471,7 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
|||||||
case "text":
|
case "text":
|
||||||
b.WriteString(blk.Text)
|
b.WriteString(blk.Text)
|
||||||
case "tool_use":
|
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()
|
out.Content = b.String()
|
||||||
|
|||||||
@@ -232,6 +232,6 @@ func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order [
|
|||||||
args = map[string]any{}
|
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"])
|
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"
|
ContentTypeText ContentType = "text"
|
||||||
// ContentTypeImage is an image content part.
|
// ContentTypeImage is an image content part.
|
||||||
ContentTypeImage ContentType = "image_url"
|
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.
|
// Content is a single, possibly multimodal, part of a message.
|
||||||
type Content struct {
|
type Content struct {
|
||||||
// Type distinguishes a text part from an image part.
|
// Type distinguishes a text part from an image / tool part.
|
||||||
Type ContentType
|
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
|
Text string
|
||||||
// ImageURL is the image source when Type == ContentTypeImage. It may be a
|
// ImageURL is the image source when Type == ContentTypeImage. It may be a
|
||||||
// data: URI or an http(s):// URL.
|
// data: URI or an http(s):// URL.
|
||||||
ImageURL string
|
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.
|
// 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
|
// channel layer. Empty means the sender is unlabeled (the owner, or an
|
||||||
// unknown/unresolved sender), and callers may leave it unset.
|
// unknown/unresolved sender), and callers may leave it unset.
|
||||||
SenderName string
|
SenderName string
|
||||||
// ToolCallID and ToolCallName are reserved as interface-level placeholders
|
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
|
||||||
// for the M2 tool loop; they are ignored by current adapters.
|
// 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
|
ToolCallID string
|
||||||
ToolCallName string
|
ToolCallName string
|
||||||
}
|
}
|
||||||
@@ -84,6 +106,25 @@ func ImageMessage(role Role, text, imageURL string) Message {
|
|||||||
return Message{Role: role, Content: parts}
|
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
|
// ToolSpec is an interface-level placeholder for a tool/function schema. The
|
||||||
// full tool loop (registration, argument binding, result re-feed) is M2; here
|
// 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.
|
// 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
|
// (never a serialized JSON string), so a tool loop can bind it directly without
|
||||||
// re-parsing.
|
// re-parsing.
|
||||||
type ToolCall struct {
|
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 is the tool being invoked.
|
||||||
Name string
|
Name string
|
||||||
// Args are the tool arguments, decoded from the provider's structured
|
// 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
|
// 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 {
|
type openAIMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content any `json:"content"`
|
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).
|
// openAITool is the wire form of a function tool schema (M2 placeholder).
|
||||||
@@ -88,6 +101,7 @@ type openAIChoice struct {
|
|||||||
type openAIResponseMessage struct {
|
type openAIResponseMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// openAIDelta is the incremental payload inside a stream chunk. Reasoning content
|
// 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) {
|
func buildMessages(msgs []Message) ([]openAIMessage, error) {
|
||||||
out := make([]openAIMessage, 0, len(msgs))
|
out := make([]openAIMessage, 0, len(msgs))
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
content, err := encodeContent(m.Content)
|
wire, err := encodeOpenAIMessage(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, openAIMessage{Role: string(m.Role), Content: content})
|
out = append(out, wire)
|
||||||
}
|
}
|
||||||
return out, nil
|
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) {
|
func encodeContent(parts []Content) (any, error) {
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if len(parts) == 1 && parts[0].Type == ContentTypeText {
|
|
||||||
return parts[0].Text, nil
|
|
||||||
}
|
|
||||||
arr := make([]any, 0, len(parts))
|
arr := make([]any, 0, len(parts))
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
switch p.Type {
|
switch p.Type {
|
||||||
@@ -346,10 +413,23 @@ func encodeContent(parts []Content) (any, error) {
|
|||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": map[string]any{"url": p.ImageURL},
|
"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:
|
default:
|
||||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
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
|
return arr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +488,13 @@ func responseFromWire(wire *openAIChatResponse) Response {
|
|||||||
text := wire.Choices[0].Message.Content
|
text := wire.Choices[0].Message.Content
|
||||||
out.Content = text
|
out.Content = text
|
||||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: 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 {
|
if u := wire.Usage; u != nil {
|
||||||
out.PromptTokens = u.PromptTokens
|
out.PromptTokens = u.PromptTokens
|
||||||
@@ -553,7 +640,7 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
|||||||
args = map[string]any{}
|
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.Content = text.String()
|
||||||
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: agg.Content}}}
|
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)
|
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"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
"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/model"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
"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
|
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
|
// 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
|
// 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
|
// 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) {
|
func TestHubStreamChatIdle(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
hb := newTestHub(nil, nil) // nil runner
|
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}})
|
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
|
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||||
// discard logger for nil so Server can always log safely.
|
// discard logger for nil so Server can always log safely.
|
||||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||||
|
|||||||
Reference in new issue
Block a user