按 design/22 §2/§3/§4.4/§7(3/4/5)。 - ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线) - ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3) - ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId - Docs/api-client 版本 +1 - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
179 lines
11 KiB
Go
179 lines
11 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// Options configures a Loop. All fields are optional and nil is tolerated until
|
||
// the step that actually needs the field: the agent is required for Run (Run
|
||
// returns ErrNilAgent otherwise); a missing tool dispatcher causes a requested
|
||
// call to be fed back as an error; a missing retriever/persona skips memory
|
||
// injection; a missing extractor/store skips the memory hook; a missing
|
||
// checkpoint skips persistence; a missing tracer skips tracing.
|
||
type Options struct {
|
||
// Agent is the loop's execution unit: an instruction string plus a backend
|
||
// adapter, an optional tool dispatcher and optional guardrails. It is the
|
||
// DEFAULT agent: every turn whose role has no entry in Agents (or every turn
|
||
// when Agents is unset) runs on it, so its instructions should be the global
|
||
// default role's.
|
||
Agent *agent.Agent
|
||
// Agents is the per-role agent pool (design/22 §4.2 方案 A): persona id → the
|
||
// agent carrying that role's system prompt. The loop picks the entry for the
|
||
// turn's resolved role on every step, so switching a session's role switches the
|
||
// prompt without rebuilding the loop (an Agent's instructions are immutable, so
|
||
// one agent per role is the way to keep that invariant). All entries normally
|
||
// share one dispatcher/guardrail pipeline/adapter — only the instructions differ.
|
||
// A missing or nil entry, an unknown role, or an empty pool all fall back to
|
||
// Agent. Nil keeps the pre-existing single-role behavior.
|
||
Agents map[string]*agent.Agent
|
||
// Tools is the tool dispatcher used to execute model-requested calls. When
|
||
// nil the loop falls back to the agent's own dispatcher (if any).
|
||
Tools *tool.Dispatcher
|
||
// Capacity, when non-nil and enabled, filters the tool schemas advertised to
|
||
// the model each round to the allowed set for the current capacity state
|
||
// (design/14 唤醒式能力渐进 §4): the loop only exposes the allowed set to the
|
||
// model — a view, not a hard boundary (Invoke is left unblocked so an
|
||
// emergency call still works). Nil (the default) keeps the pre-existing
|
||
// behavior: every registered tool is advertised.
|
||
Capacity *capacity.Decider
|
||
// OnCapacity, when non-nil, receives capacity_awaken transition events the
|
||
// loop observes while deciding a round (design/14 §4), so a caller can
|
||
// surface the "能力点亮" progression to the frontend. Nil = no-op.
|
||
OnCapacity func(capacity.Event)
|
||
// OnAudit, when non-nil, receives an AuditEvent every time the loop denies a
|
||
// tool call in a restricted (group/stranger) environment (design/11 §3 §8),
|
||
// so the rejection lands in the audit trail and the frontend can render the
|
||
// small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the
|
||
// model as a soft not_allowed result, just not recorded to an external sink.
|
||
OnAudit func(AuditEvent)
|
||
// OnTurnEnd, when non-nil, is called once per settled turn (a Run that
|
||
// returned a Result) with the turn's user/assistant text and the persona the
|
||
// loop is bound to, so a caller can advance state that follows the persona —
|
||
// the emotion machine and affection curve of design/01 §2. Its return value
|
||
// is recorded on Result.Mood and carried to the persisted assistant message.
|
||
// Nil = the loop reports nothing and Result.Mood stays empty.
|
||
OnTurnEnd TurnEndHook
|
||
// Memory is the long-term memory store used to persist extracted items.
|
||
Memory *memory.Store
|
||
// Retriever injects relevant memories into the context, re-evaluated every
|
||
// round (see assembleContext) so retrieval tracks the latest conversation.
|
||
Retriever *memory.Retriever
|
||
// WorkingMemory is the per-session sliding window, provided for the
|
||
// orchestrator to inspect; the loop does not mutate it.
|
||
WorkingMemory *memory.WorkingMemory
|
||
// Extractor derives memory items to persist after every step.
|
||
Extractor memory.Extractor
|
||
// Checkpoint persists a durable snapshot of the loop state at every step and,
|
||
// on a later Run with the same sessionID, resumes an interrupted run from it
|
||
// (design §1 durable execution). *persist.Store satisfies the narrow
|
||
// checkpointStore interface; nil disables both persistence and resume.
|
||
Checkpoint checkpointStore
|
||
// Tracer emits a structured span for every step, LLM call and tool call.
|
||
Tracer *trace.Tracer
|
||
// Flow, when non-nil, accumulates the run's trace spans into a per-session
|
||
// flow graph and publishes ws.graph_update on each span transition (design/19
|
||
// §1). Nil disables the realtime flow visualization.
|
||
Flow *FlowPublisher
|
||
// Guards validates request/response payloads on the streaming path, where
|
||
// RunStep is bypassed. On the non-streaming path the agent runs its own
|
||
// guardrails inside RunStep.
|
||
Guards *guardrail.Pipeline
|
||
// PersonaID is the loop's configured role: the global default that every turn
|
||
// without an explicit SessionEnv.PersonaID runs as (design/22 §2 裁决规则 4). It
|
||
// scopes memory retrieval and extraction, the system prompt fallback and the
|
||
// emotion hook. Blank skips memory injection/extraction.
|
||
PersonaID string
|
||
// PersonaName, when non-nil, resolves a role id to its display name for the
|
||
// context's speaker attribution (design/22 §7-5): the role declaration and the
|
||
// boundary notes name a speaking role as "「昔涟」(xilian)" instead of a bare id,
|
||
// so the model can follow "who said this" without a lookup table of its own. An
|
||
// unknown role, an empty name or a nil resolver all fall back to the id — the
|
||
// attribution never disappears because a name could not be resolved.
|
||
PersonaName func(personaID string) string
|
||
// PersonaKnobs, when non-nil, resolves the reasoning knobs for a role at the
|
||
// start of every request build (design/22 §4.2), so a session bound to role Y
|
||
// uses Y's thinking budget / effort / broadcast switch rather than the default
|
||
// role's. The returned ok=false means "role unknown" and keeps the loop's
|
||
// configured defaults (Options.Thinking / ReasoningEffort / ThinkingShowToUser).
|
||
// Resolving through the role id — rather than a caller-supplied per-turn value —
|
||
// is what keeps every path (gateway, channelbridge, backthink, sub-agents) on the
|
||
// right role's knobs from a single input. Nil keeps the configured defaults for
|
||
// every turn, i.e. the pre-multi-character behavior.
|
||
PersonaKnobs func(personaID string) (LoopKnobs, bool)
|
||
// Timezone is the IANA zone used to render the per-round "current time"
|
||
// system message (e.g. "Asia/Shanghai"). Empty disables time injection
|
||
// entirely (backward compatible); a name that fails to load also disables it.
|
||
Timezone string
|
||
// MaxRounds is the tool-loop upper bound. Non-positive selects
|
||
// DefaultMaxRounds (8).
|
||
MaxRounds int
|
||
// OnStream, when non-nil, switches the loop to the streaming path: the model
|
||
// adapter's ChatStream is driven directly and each structured stream event is
|
||
// forwarded to this callback (model.StreamEvent), so the frontend can render
|
||
// body text, tool calls and (eventually) reasoning segments independently.
|
||
OnStream func(model.StreamEvent)
|
||
// Thinking carries the reasoning knobs (budget / explicit switch) injected
|
||
// into every model request the loop builds when no per-role value overrides it.
|
||
// nil means "do not intervene", which is the pre-existing behavior. It is the
|
||
// DEFAULT for turns whose role has no resolved knobs (Options.PersonaKnobs).
|
||
Thinking *model.Thinking
|
||
// ReasoningEffort is the OpenAI-compatible strength knob injected into every
|
||
// model request. Empty means "do not intervene".
|
||
ReasoningEffort string
|
||
// ThinkingShowToUser gates the thinking frames the loop broadcasts: nil (the
|
||
// default) keeps the pre-existing behavior and pushes them; an explicit false
|
||
// keeps thinking_start/delta/end out of the client stream while text and tool
|
||
// frames are unaffected. The frames are still produced and still reach the
|
||
// model/transcript paths — only the UI broadcast is gated, so display and
|
||
// persistence stay decoupled (design/04 §3.2).
|
||
ThinkingShowToUser *bool
|
||
// ToolExecution selects how a batch of tool calls is run: "parallel" (the
|
||
// default) or "sequential". Empty means parallel.
|
||
ToolExecution string
|
||
// ToolParallelism caps how many tool calls run concurrently when ToolExecution
|
||
// is "parallel". Non-positive selects DefaultToolParallelism (4).
|
||
ToolParallelism int
|
||
// Hooks installs optional per-batch/per-call tool-loop hooks. Every field is
|
||
// nil-tolerant; these are reserved for "敏感操作代审" (design/11) and the
|
||
// future asynchronous-tool path. A zero Hooks value is a no-op.
|
||
Hooks ToolLoopHooks
|
||
// MaxContextTokens is the context budget that triggers overflow compaction.
|
||
// Non-positive (the zero value) disables compaction, preserving the loop's
|
||
// previous behavior. Setting it to a positive value (e.g.
|
||
// DefaultMaxContextTokens) enables compaction: when the loop's estimated
|
||
// context exceeds the budget it summarizes older turns into a leading summary
|
||
// message while keeping the recent tail (including trailing tool results)
|
||
// intact (design §1.3). Nil-tolerant: leaving it unset changes nothing.
|
||
MaxContextTokens int
|
||
// Compactor compacts an over-budget conversation. Nil selects the built-in
|
||
// structural compactor (no model call); a non-nil value lets a caller inject
|
||
// an LLM-backed or custom summarizer. It is only invoked when
|
||
// MaxContextTokens is positive.
|
||
Compactor Compactor
|
||
// Vision, when non-nil, rewrites an image-bearing user message at the start
|
||
// of Run so the main model can reason over the image (design/06 §0). The
|
||
// concrete resolver (vision.MessageResolver) decides via the vision Router
|
||
// whether to keep the image for a vision-capable main model or fold it into a
|
||
// text description from the vision/OCR tool ("先看图再对话"). Nil keeps the
|
||
// image content as-is, passing it through to a vision-capable main model.
|
||
Vision ImageResolver
|
||
// TransformContext, when non-nil, is called immediately before every model
|
||
// step with the assembled message list (after memory injection and any
|
||
// compaction) and the current round number (1-based). It may project, filter
|
||
// or re-order the messages the model sees. It is the loop's equivalent of
|
||
// OpenClaw's transformContext (design §1.3). Nil = no projection.
|
||
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
|
||
// Log is the base logger. Nil is replaced with a discard logger tagged with
|
||
// source "runtime/loop".
|
||
Log *slog.Logger
|
||
}
|