Documentation
¶
Overview ¶
Package loop is celeste's one tool loop (2.0 F2): every mode streams a request, runs the model's tool calls and feeds the results back through Loop.Run. Permissions (Gate), hooks, limits and compaction are separate control planes supplied by each adopter.
Index ¶
- Constants
- Variables
- func ConfigStamp(ws string) string
- func HookContextBlock(context string) string
- func UserPromptSubmit(ctx context.Context, h *hooks.Runner, msg Message) (Message, PromptVerdict, error)
- type Boundary
- type Compactor
- type Env
- func (e *Env) Close()
- func (e *Env) Nested(opts NestedOptions) (*Env, error)
- func (e *Env) PersistRules()
- func (e *Env) RefreshDiscovery()
- func (e *Env) RenderState() string
- func (e *Env) SessionStartContext(ctx context.Context, source string) string
- func (e *Env) StartSession(ctx context.Context, source string)
- func (e *Env) SystemPrompt(o PromptOptions) prompts.Prompt
- func (e *Env) Trust()
- type Event
- type EventKind
- type Gate
- type GateFunc
- type LLM
- type Limits
- type Loop
- type Message
- type Mode
- type NestedOptions
- type Nester
- type Parent
- type PromptCheck
- type PromptOptions
- type PromptVerdict
- type Reminder
- type Result
- type SetupOptions
- type Steering
- type StopReason
- type StreamEnder
- type ToolCall
- type TurnTimeoutError
Constants ¶
const ( // DefaultMaxTurns bounds a Run whose Limits.MaxTurns is unset. DefaultMaxTurns = 25 // DefaultToolTimeout bounds a tool without its own tools.Timeouter. DefaultToolTimeout = 45 * time.Second // DefaultHookBudget covers one PreToolUse and one PostToolUse hook at // F0's default 30 s timeout each. DefaultHookBudget = 60 * time.Second )
const DefaultMaxRuleInterrupts = 2
DefaultMaxRuleInterrupts bounds re-runs of one turn. Past it, steering fails open: the reply goes through and the interrupt is dropped.
const MetaReminder = "steering_reminder"
MetaReminder marks a reminder message in the history; its value is the Reminder's Source.
const ToolDiscoveryThreshold = 40
ToolDiscoveryThreshold turns on discovery mode (MCP tools hidden until find_tools activates them) once the registry is this big.
Variables ¶
var ErrRuleInterrupt = llm.ErrRuleInterrupt
ErrRuleInterrupt marks a request a stream rule or the watchdog cut short (2.0 W3). The loop re-runs the turn; Run never returns it.
var ErrTurnTimeout = errors.New("loop: request exceeded its per-turn timeout")
ErrTurnTimeout matches (errors.Is) a request that hit Limits.RequestTimeout.
Functions ¶
func ConfigStamp ¶
ConfigStamp fingerprints (path, mtime, size) the config files Setup bakes into an Env, so an owner that keeps an Env can replace it as soon as one changes: a new deny rule, hook, trust approval, skill, MCP server, grimoire or AGENTS.md / CLAUDE.md edit applies to the next run. A directory's mtime covers files added to or removed from it; an edit inside one is not seen. MCP chat's Env cache and Parent use it.
func HookContextBlock ¶
HookContextBlock is how UserPromptSubmit context joins the prompt the model receives: after the prompt, in a <hook-context> block.
func UserPromptSubmit ¶
func UserPromptSubmit(ctx context.Context, h *hooks.Runner, msg Message) (Message, PromptVerdict, error)
UserPromptSubmit runs h's UserPromptSubmit hooks on msg. The chat, MCP chat and agent goals share it (2.0 F2e). A deny, or a hook that failed, blocks msg with the hook's reason ("no reason given" when it has none). Additional context rides in msg.Metadata[tui.MetaHookContext]; the loop appends it to the copy it sends (HookContextBlock). A hook cut short by ctx ending returns ctx's error: an interrupt, not a verdict. With no UserPromptSubmit hooks (h may be nil) msg is allowed unchanged.
Types ¶
type Compactor ¶
type Compactor interface {
Compact(ctx context.Context, history []Message, lastUsage *llm.TokenUsage, force bool) (out []Message, notes []string, changed bool)
}
Compactor keeps the history inside the window. The loop calls it before every request, and once with force set after a context-overflow error. lastUsage is the provider's count for the previous request (nil when there was none since the last call). notes become EventCompacted. Compaction hooks (PreCompact/PostCompact) are the compactor's business: it knows when a summary is written.
type Env ¶
type Env struct {
Mode Mode
Workspace string
Registry *tools.Registry
Checker *permissions.Checker
ToolMode tools.RuntimeMode
MCP *mcp.Manager
Indexer *codegraph.Indexer
Hooks *hooks.Runner // tool hooks run in Registry; nil when loading failed
Files *checkpoints.FileTracker
Snapshots *checkpoints.SnapshotManager
ProjectContext string // grimoire, context files, code-graph summary
GitSnapshot string
// Memories is the "# Project Memories" block. It follows git in the
// prompt (W5 ruling 8); GrimoireContext still shows it.
Memories string
GrimoireContext string // grimoire and "# Project Memories" (the /grimoire view)
CodeGraphSummary string // the code graph's project summary (the /index view)
// Rules are the stream rules for this workspace (2.0 W3): built-ins,
// ~/.celeste/rules/*.md, then the grimoire's "## Stream Rules" (a repo
// grimoire's only once trusted, like a repo hook).
Rules *rules.Set
// SandboxPolicy is bash's OS sandbox (2.0 W4): defaults, the user's
// "sandbox" settings, then the workspace's (loosening only once
// trusted). A nested Env in the same workspace inherits it; one in
// another workspace resolves its own.
SandboxPolicy sandbox.Policy
// contains filtered or unexported fields
}
Env is everything a mode needs around the loop.
func Setup ¶
Setup builds the registry, MCP clients, custom skills, permission checker, memories, grimoire, git snapshot, code graph and hooks for mode (2.0 F2). The caller owns Close.
func (*Env) Close ¶
func (e *Env) Close()
Close releases this Env. A Setup Env and its Nested children share MCP clients and a code graph, which stop when the last of them closes, so a child still running keeps them open after its parent closes. A child in another workspace also closes its own code graph. A code-graph update still running is cancelled before it is waited on, so this returns promptly. Safe to call more than once (and concurrently).
func (*Env) Nested ¶
func (e *Env) Nested(opts NestedOptions) (*Env, error)
Nested builds the Env for a runner started by another run (a subagent, an orchestrator lane), skipping MCP and code-graph startup. The child shares the parent's MCP clients (their tools are mirrored into its registry) and, in the same workspace, the parent's hooks runner, code graph and project context; it re-captures the git state and brings the shared code graph up to date first (bounded by nestedCodeGraphTimeout). It always gets its own registry (builtins, custom skills; never spawn_agent), its own permission checker reloaded from permissions.json (never a parent's Trust()), and its own file tracker and snapshots. The shared parts stay open until the parent and every child have closed. Agent-mode and chat Envs built by Setup can nest; a child is always agent mode. A chat parent (subagents and /agent in the chat, 2.0 F2e) shares only its global MCP servers: a child is a non-interactive run, and those never run a repo's MCP servers.
func (*Env) PersistRules ¶
func (e *Env) PersistRules()
PersistRules makes the checker save "always allow" and "always deny" answers to permissions.json, as the chat's does. An adopter calls it only when the answers come from the user's interactive prompt (the TUI modal behind /agent and /orchestrate lanes); headless runs never write. A rule that can't be saved (a damaged permissions.json is never overwritten) still applies for this run, and the failure is warned.
func (*Env) RefreshDiscovery ¶
func (e *Env) RefreshDiscovery()
RefreshDiscovery turns tool discovery on when tools added after Setup (the chat's config-backed skills, collections, spawn_agent, post_message) take the registry past ToolDiscoveryThreshold. It never turns it off.
func (*Env) RenderState ¶
RenderState renders the authoritative state a compaction summary carries (#200): the workspace todo list (read from its file, which the todo tool rewrites on every change), the files this Env's checkpoint session changed (F4), relative to the workspace when inside it, and the voice rule (the persona is always on, W5).
func (*Env) SessionStartContext ¶
SessionStartContext fires SessionStart and returns its additional context, leaving the Env unchanged, so several runs sharing one Env each get their own session. Pass the result as PromptOptions.Session.
func (*Env) StartSession ¶
StartSession fires SessionStart for a top-level run and adds its context to the project context, under the TUI's heading. Call it before SystemPrompt; nested runs (subagents, orchestrator lanes) don't call it. A shared Env (MCP chat) uses SessionStartContext instead.
func (*Env) SystemPrompt ¶
func (e *Env) SystemPrompt(o PromptOptions) prompts.Prompt
SystemPrompt composes the mode's system prompt (W5): the persona profile as the byte-stable Static part, stepped down for the window, then sliders, identity, mode rules, project context, git, memories and the date. A Notice in the result is the small-window guard's one-time message; the caller decides where it shows (W5 ruling 7).
type Event ¶
type Event struct {
Kind EventKind
Turn int
Text string
ToolNames []string
Usage *llm.TokenUsage
Elapsed time.Duration
Call ToolCall
Msg Message
Metadata map[string]any // EventToolResult: the tool's ToolResult.Metadata (images, subagent names)
IsError bool
History []Message
Result Result
Err error
// At is EventToolStart's start time, taken on the loop's goroutine, so
// it precedes every message the loop stamps after the call (C5).
At time.Time
}
Event is one step of a Run, for renderers and adopters.
type EventKind ¶
type EventKind int
EventKind identifies an Event.
const ( // EventTurnStart (Turn) may repeat with the same Turn number after an // overflow retry re-runs the turn: consumers should key on Turn, not // count events. EventTurnStart EventKind = iota EventTextDelta // Text EventAssistant // Turn, Text, ToolNames, Usage, Elapsed: the model's reply EventToolStart // Call EventToolResult // Call, Text (what the model receives), IsError EventCompacted // Text EventSteered // Text, Msg: a steer joined (Msg as it joined, metadata included) EventNotice // Text: a non-fatal problem (hook error, spill failure) EventTurnEnd // Turn, History: a consistent history snapshot EventDone // Result, Err: always the last event of a Run EventPromptBlocked // Msg, Text (the reason): CheckPrompt blocked a prompt; it left the history EventSteerBlocked // Msg, Text (the reason): CheckPrompt blocked a steer; it was dropped EventPromptsChecked // History: CheckPrompt allowed a prompt; the checked history, before the first request EventCallsRecorded // Turn, History: the snapshot with this turn's assistant tool_calls message, before any call runs EventRule // Text (the Reminder's Source), Msg: a steering reminder joined the history (W3) EventRuleInterrupt // Turn, Text (the re-run's reminder sources, ", "-joined), Usage, Elapsed: steering cut the turn's reply short; it is dropped and the turn re-runs (W3) EventThinking // Text: a delta of the model's reasoning, for a thinking indicator; never reply text or history (L4) )
type Gate ¶
type Gate interface {
Ask(ctx context.Context, req tools.PermissionRequest) tools.PermissionResponse
}
Gate answers a permission Ask for one run. Its lifetime differs by mode (TUI modal, agent prompt or none, MCP none), so each adopter passes it in. A nil Gate means an Ask is denied. Loop serializes calls to Ask itself (gateMu in exec.go), so a Gate implementation need not handle concurrent calls even when parallel-safe tool calls all Ask in the same batch.
func PromptGate ¶
func PromptGate(fn tools.PromptFunc) Gate
PromptGate adapts a blocking tools.PromptFunc (the TUI modal) to a Gate. A nil fn gives a nil Gate. Asks are serialized: parallel calls in one batch must not open two modals at once. When ctx ends first (the call was abandoned, Esc, or the chat quitting) the ask answers deny at once; the prompt's own goroutine keeps the lock until the prompt returns, so a later ask never opens a second modal over one still showing. An ask whose ctx ended while it waited for the lock never calls fn.
type GateFunc ¶
type GateFunc func(ctx context.Context, req tools.PermissionRequest) tools.PermissionResponse
GateFunc adapts a function to Gate.
func (GateFunc) Ask ¶
func (f GateFunc) Ask(ctx context.Context, req tools.PermissionRequest) tools.PermissionResponse
type LLM ¶
type LLM interface {
SendMessageStreamEvents(ctx context.Context, messages []tui.ChatMessage, tools []tui.SkillDefinition, cb llm.StreamEventCallback) error
// GetSkills is the tools a request offers, and so the only ones the
// loop runs: an implementation that offers none returns an empty,
// non-nil slice (nil places no restriction; test stubs only).
GetSkills() []tui.SkillDefinition
}
LLM is the part of *llm.Client the loop uses.
type Limits ¶
type Limits struct {
MaxTurns int // <=0: DefaultMaxTurns
MaxCallsPerTurn int // calls beyond it are dropped before the turn is recorded
IdenticalCalls int // stop when the identical call batch repeats this many turns
NoProgressTurns int // stop when tools return identical results this many turns
SpillBytes int // results larger than this spill to disk
ToolTimeout time.Duration // <=0: DefaultToolTimeout; a tools.Timeouter overrides it
RequestTimeout time.Duration // per-request deadline; the client's per-attempt deadline still applies
MaxInvalidArgTurns int // stop after this many turns in a row with bad tool arguments
TextToolCalls bool // parse <tool_call> blocks from text when a turn has no native calls
// KeepToolMetadata puts an image result's ToolResult.Metadata on its
// tool message (read_file for vision models), with a marker in the
// content. The chat sets it; agent and MCP runs send text only.
KeepToolMetadata bool
// MaxRuleInterrupts bounds re-runs of one turn by steering (W3);
// <=0: DefaultMaxRuleInterrupts.
MaxRuleInterrupts int
// HookBudget is how long PreToolUse/PostToolUse hooks may add to a call
// before the watchdog abandons it (tool timeout + budget; gated runs:
// counted from the Gate's answer). <=0: DefaultHookBudget.
HookBudget time.Duration
}
Limits is plain data. For guards, caps and spill, zero means off.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits are the spec's values: 25 turns, identical-call guard 3, progress guard 6, spill at 128 KiB, 45 s per tool.
type Loop ¶
type Loop struct {
Client LLM
Tools *tools.Registry
Limits Limits
Gate Gate // nil: an Ask is denied
Compact Compactor // nil: no compaction
// CheckPrompt is UserPromptSubmit for this run (2.0 F2d; nil: none).
CheckPrompt PromptCheck
// Steering is stream rules and the watchdog (2.0 W3; nil: none).
Steering Steering
// Advisor is jev_gate (2.0 W3; nil: none): it may turn an allowed call
// into an Ask, never the reverse.
Advisor tools.AskAdvisor
// Refuse, when set, is asked for every call to a known tool before it
// reaches the registry; a non-empty reason settles the call as an
// error without running it, its hooks or its permission check (the
// chat's plan mode, 2.0 W4e). It can only take calls away. It runs on
// Run's goroutine and is read at call time.
Refuse func(name string) string
// Tool hooks run inside Tools (F0); the loop fires no hooks itself.
// SessionID names the spill directory for oversized results.
SessionID string
// SpillDir overrides the spill base directory; "" uses the default
// (ctxmgr.ToolResultsBaseDir). Only tests set it. recall_tool_result
// reads from the default base, so with a custom SpillDir the recall id
// in a capped result's marker does not resolve: the spill file is still
// written and named, but only that path reaches it.
SpillDir string
// SpillCounter numbers spill files across Loops that share it (the chat
// starts one Loop per turn, 2.0 F2d), so a call ID repeated in a later
// run never overwrites an earlier spill. nil: counted per Loop.
SpillCounter *atomic.Int64
// contains filtered or unexported fields
}
Loop runs one conversation's tool loop. Configure the exported fields, then call Run (once per step; the Loop may be reused).
func (*Loop) Events ¶
Events returns the event stream. Call it before Run. Sends are unbuffered: the loop waits for the consumer to take each event, so a consumer that has received event N+1 has finished handling event N, and once Run returns the consumer has handled everything before EventDone. A consumer must keep reading until EventDone. Without a call to Events the loop emits nothing.
func (*Loop) Run ¶
Run drives turns until the model answers without tools, a limit or guard stops it, the provider fails, or ctx is cancelled (an interrupt). It returns a copy of history with the run's messages appended. Every declared tool call in the returned history has a result.
func (*Loop) Steer ¶
Steer queues a user message. It joins the conversation at the next tool boundary (before the next request). Safe from any goroutine.
func (*Loop) TakeSteers ¶
TakeSteers removes and returns the steers no run has joined: typed after the last tool boundary of a run that then ended (an interrupt, a guard, a cap). The caller decides what to do with them; the chat sends them next.
type Message ¶
type Message = tui.ChatMessage
Message is one chat message. The loop copies the history it is given and only appends to the copy.
func ReminderMessage ¶
ReminderMessage is a reminder as the history holds it: a hidden user message (not shown in the chat, never checked by UserPromptSubmit) in a <system-reminder> block.
type NestedOptions ¶
type NestedOptions struct {
// Workspace is the child's workspace; "" means the parent's. A different
// one (an isolated worktree) gets its own hooks, project context and
// code graph.
Workspace string
// Warn receives the child's own setup warnings; nil means the parent's
// sink. A shared hooks runner reports to the sink in the ctx of the run
// that fired the hook (hooks.WithWarn), else to the parent's.
Warn func(string)
// Notice receives the child's timing notices (a git snapshot or
// code-graph update that ran out of time); nil means Warn when Warn is
// set, else the parent's.
Notice func(string)
}
NestedOptions configures a child Env.
type Nester ¶
type Nester interface {
Nested(NestedOptions) (*Env, error)
}
Nester builds the Env for a runner started by another run: an *Env built by Setup, or a *Parent.
type Parent ¶
type Parent struct {
// contains filtered or unexported fields
}
Parent is the Env the nested runners of one owner share (the subagent manager's, or one orchestrator run's). The first Nested call builds it, and a later one rebuilds it when ConfigStamp(workspace) has changed, so a new deny rule, hook, trust approval, skill or MCP server applies from the next runner. A replaced Env closes when the last runner nested under it closes.
func NewParent ¶
func NewParent(cfg *config.Config, workspace string, opts SetupOptions) *Parent
NewParent returns a Parent for workspace. Nothing is built until the first Nested call.
func (*Parent) Close ¶
func (p *Parent) Close()
Close releases the current Env; runners still using it keep its shared parts open until they close. Later Nested calls fail.
func (*Parent) Nested ¶
func (p *Parent) Nested(opts NestedOptions) (*Env, error)
Nested builds a child of the current Env (see Env.Nested), building or rebuilding that Env first as needed. It holds the Parent's lock throughout, so a rebuild never closes an Env between choosing it and nesting under it; concurrent callers wait for each other (Setup takes up to 15 s, a child's refresh up to nestedCodeGraphTimeout).
type PromptCheck ¶
PromptCheck is UserPromptSubmit for the loop. Run calls it before the first request for every user message not checked yet (MetaPromptHookDone unset, not hidden), and for every steer when it joins. It returns the message to send, with any hook context in Metadata[tui.MetaHookContext]; the loop marks it checked. An error (the run's ctx ended: an interrupt, not a verdict) leaves the message unchecked.
func HookPromptCheck ¶
func HookPromptCheck(h *hooks.Runner) PromptCheck
HookPromptCheck is h's UserPromptSubmit as a Loop.CheckPrompt, or nil when h has no UserPromptSubmit hooks, so such a run takes the unchecked path.
type PromptOptions ¶
type PromptOptions struct {
// Contract is the agent operating contract (agent mode only).
Contract string
// Sliders overrides slider.json (a subagent's persona override).
Sliders *config.SliderConfig
// Session is one run's SessionStart context; it never enters the Env.
Session string
// Level is the persona level: "" is the mode's profile (chat full,
// agent spine); prompts.PersonaOff for typed explore and review
// subagents (2.0 W4e) and orchestrator lanes.
Level prompts.PersonaLevel
// Window is the run's model's context window; 0 uses the Env's.
Window int
}
PromptOptions is what one system prompt adds to the Env's own inputs.
type PromptVerdict ¶
PromptVerdict is a PromptCheck's answer.
type Result ¶
type Result struct {
FinalText string // last assistant text, verbatim
ToolCallsLastTurn int
NoToolTurns int // consecutive tool-free turns at the end of this Run
StopReason StopReason
Turns int // turns started this Run (an overflow retry is the same turn)
ToolCalls int // tool calls executed this Run
// HistoryEdited: the returned history holds an edit (a prune, or
// provider blocks stripped after BlocksRejected) that no History
// snapshot event carried, because the run ended first. A consumer that
// mirrors the snapshots takes the returned history (2.0 F3).
HistoryEdited bool
}
Result describes one Run. The completion gate is not here: the agent layer (and W3) read it.
type SetupOptions ¶
type SetupOptions struct {
SessionID string // hooks' session_id and checkpoint session; "" = "<mode>-<pid>-<start nanos>"
Warn func(string) // setup and hook warnings; nil = stderr
// Notice gets timing notices (a git snapshot or code-graph update that
// ran out of time). They depend on the machine, not the configuration.
// nil = Warn.
Notice func(string)
// Approve asks the person to trust a repo hook. Only ModeChat uses it
// (non-interactive modes never approve, F0); nil there means a terminal
// prompt when stdin and stderr are terminals.
Approve hooks.ApproveFunc
// GlobalMCPOnly skips the workspace's MCP configs in ModeChat too, as
// every other mode does: a chat whose user cannot be asked before a
// repo's server starts (ACP, where the editor passes its own servers).
GlobalMCPOnly bool
}
SetupOptions carries what only the adopter knows.
type Steering ¶
type Steering interface {
// Request is called before each request. interrupt cuts that request
// short; it is a no-op once the request has returned, and nil when the
// turn has used its re-runs (Observe's answer is then ignored too).
Request(turn int, interrupt func())
// Observe sees every event before consumers do. True interrupts the
// request in flight (only text deltas arrive during one).
Observe(ev Event) bool
// Calls sees a turn's complete tool calls before they are recorded or
// run. True drops the turn's reply and re-runs it.
Calls(turn int, calls []ToolCall) bool
// Reminders removes and returns the reminders due at b.
Reminders(b Boundary) []Reminder
}
Steering is stream rules and the watchdog as the loop sees them (W3). Its methods are called on Run's goroutine, except the interrupt function Request hands over, which is safe from any goroutine.
type StopReason ¶
type StopReason string
StopReason says why Run returned.
const ( StopDone StopReason = "done" // a turn with no tool calls StopCap StopReason = "cap" // Limits.MaxTurns StopIdentical StopReason = "identical" // identical-call guard StopProgress StopReason = "progress" // progress guard StopInterrupted StopReason = "interrupted" // ctx cancelled StopInvalidArgs StopReason = "invalid_args" // Limits.MaxInvalidArgTurns StopError StopReason = "error" // the provider failed; Run returns the error StopBlocked StopReason = "blocked" // CheckPrompt blocked every new prompt; no request was sent )
type StreamEnder ¶
type StreamEnder interface {
EndStream() bool
}
StreamEnder is an optional Steering method. EndStream is called when a request's stream has completed without an error, before the request returns: a matcher that batches its scans checks what it held back. True interrupts the request (the reply is dropped even though the provider sent all of it).
type ToolCall ¶
type ToolCall struct {
ID string
Name string
Input map[string]any
// Key names this one invocation, unique in the process: a model's IDs
// may repeat or be empty, so whatever binds a permission ask to the
// call it shows (ACP) keys on Key, never on ID. It is also on the
// call's context (tools.CallKeyFromContext). Empty outside runCalls.
Key string
}
ToolCall is one call as hooks and events see it.
type TurnTimeoutError ¶
TurnTimeoutError keeps the provider's message unchanged and marks it as a per-turn timeout.
func (*TurnTimeoutError) Error ¶
func (e *TurnTimeoutError) Error() string
func (*TurnTimeoutError) Is ¶
func (e *TurnTimeoutError) Is(target error) bool
func (*TurnTimeoutError) Unwrap ¶
func (e *TurnTimeoutError) Unwrap() error