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 // 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 }