// Package loop implements the default agent execution engine of the Cyrene v2 // runtime (DevDocs/design/04-运行时.md §1). It owns the loop that package agent // deliberately leaves out: assemble context from memory, drive the model for up // to MaxRounds steps, execute any requested tool calls under a per-invoke // timeout, feed tool results back to the model, persist a checkpoint at every // step, emit structured traces, deliver streamed deltas and run the // memory-extraction hook. // // The loop is configured with injected dependencies rather than concrete wiring, // so it stays leaf-testable with mocks (a mock adapter, mock guardrail and mock // tool, plus real in-memory memory/persist stores and a buffered trace recorder). package loop import ( "context" "errors" "io" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "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" ) // Constants that bound the loop. These are compile-time so no magic number // appears in the run path. const ( // DefaultMaxRounds is the tool-loop upper bound used when Options.MaxRounds // is non-positive. It matches the agent package's own default. DefaultMaxRounds = 8 // StopReasonMaxRounds reports that the loop exhausted its round budget // before reaching a final answer. StopReasonMaxRounds = "max_rounds" // StopReasonToolLoopTerminated reports that the critical-tool-loop safety net // stopped the run after the same tool call was repeated identically across // consecutive rounds. StopReasonToolLoopTerminated = "tool_loop_terminated" // DefaultToolParallelism caps how many tool calls run concurrently when the // loop runs a batch in parallel mode and no explicit cap is configured. DefaultToolParallelism = 4 ) // Sentinel errors returned by the Loop. Use errors.Is to test for them; they are // returned directly or wrapped with %w so the underlying cause stays available. var ( // ErrNilAgent is returned by Run when no *agent.Agent was configured. ErrNilAgent = errors.New("loop: nil agent") // ErrNoAdapter is returned by Run (streaming path) when the agent was built // without a model Adapter. ErrNoAdapter = errors.New("loop: no model adapter") ) // Result is the outcome of a completed Run. type Result struct { // Text is the final assistant text, or empty when the loop exhausted its // budget without a final answer. Text string // Steps is the number of model steps executed. Steps int // StopReason classifies how the loop ended: an agent StopReason* value or // StopReasonMaxRounds. StopReason string // ToolCallsExecuted is the number of tool calls the loop invoked, including // those that failed. ToolCallsExecuted int // Mood is the persona's mood label for this turn, as returned by the // OnTurnEnd hook ("" when no hook is wired). The persistence layer carries it // onto the assistant message (design/16 §3.3). Mood string } // Loop is the default agent execution engine. It is configured once via New and // safe for concurrent Run calls as long as the injected dependencies (agent, // dispatcher, stores, recorder, tracer) are themselves concurrency-safe, which // they are by contract. Run keeps all loop state on the stack. type Loop struct { agent *agent.Agent tools *tool.Dispatcher capacity *capacity.Decider onCapacity func(capacity.Event) memory *memory.Store retriever *memory.Retriever working *memory.WorkingMemory extractor memory.Extractor checkpoint checkpointStore tracer *trace.Tracer flow *FlowPublisher guards *guardrail.Pipeline personaID string timeLocation *time.Location timeZoneName string maxRounds int toolExec string toolParallelism int hooks ToolLoopHooks onStream func(model.StreamEvent) onAudit func(AuditEvent) onTurnEnd TurnEndHook maxContextTokens int compactor Compactor transformContext func(context.Context, []model.Message, int) []model.Message imageResolver ImageResolver log *slog.Logger } // New builds a Loop from opts, applying defaults. A non-positive MaxRounds // selects DefaultMaxRounds; a nil Log is replaced by a discard logger tagged with // source "runtime/loop". Every dependency may be nil and is tolerated until the // step that needs it (mirroring the run-time nil-tolerance of the underlying // packages); Run returns ErrNilAgent when no agent is configured. func New(opts Options) *Loop { maxRounds := opts.MaxRounds if maxRounds <= 0 { maxRounds = DefaultMaxRounds } toolExec := opts.ToolExecution if toolExec == "" { toolExec = toolExecParallel } toolParallelism := opts.ToolParallelism if toolParallelism <= 0 { toolParallelism = DefaultToolParallelism } compactor := opts.Compactor if compactor == nil { compactor = defaultCompactor{} } timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log)) return &Loop{ agent: opts.Agent, tools: opts.Tools, capacity: opts.Capacity, onCapacity: opts.OnCapacity, memory: opts.Memory, retriever: opts.Retriever, working: opts.WorkingMemory, extractor: opts.Extractor, checkpoint: opts.Checkpoint, tracer: opts.Tracer, flow: opts.Flow, guards: opts.Guards, personaID: opts.PersonaID, timeLocation: timeLocation, timeZoneName: timeZoneName, maxRounds: maxRounds, toolExec: toolExec, toolParallelism: toolParallelism, hooks: opts.Hooks, onStream: opts.OnStream, onAudit: opts.OnAudit, onTurnEnd: opts.OnTurnEnd, maxContextTokens: opts.MaxContextTokens, compactor: compactor, transformContext: opts.TransformContext, imageResolver: opts.Vision, log: sourcedLog(opts.Log), } } // MaxRounds returns the tool-loop upper bound configured for the loop. func (l *Loop) MaxRounds() int { return l.maxRounds } // Agent returns the injected agent. func (l *Loop) Agent() *agent.Agent { return l.agent } // sourcedLog returns a logger tagged with the module source "runtime/loop", // replacing a nil logger with a discard one so the loop can always log safely. func sourcedLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(io.Discard, nil)) } return logger.WithSource(l, "runtime/loop") }