Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Change Loop configuration

Queue supported model, effort, mode, and runtime changes at a safe boundary.

developer

Loop configuration changes are actor-owned and take effect at the next turn boundary. A running turn keeps the mode, model, effort, and tool registry it started with. The public application surface is a loop.Controller; the runtime sends live command values internally and records the committed configuration as enduring events.

Public controller contract

type Controller interface {
	Handle
	SetMode(context.Context, ModeName) error
	Change(context.Context, ...Change) error
	Interrupt(context.Context) error
}

type Change interface {
	InferenceModel() (model.Model, bool)
	InferenceEffort() (model.Effort, bool)
	change()
}

func ChangeModel(model model.Model) Change
func ChangeEffort(effort model.Effort) Change

The Change marker is sealed. Consumers use ChangeModel and ChangeEffort, not a hand-written implementation. A batch is folded last-write-wins per field and validated atomically by the actor. An empty batch returns *loop.ChangeError{Kind: loop.ChangeNoChanges} before a command is sent.

The corresponding live command types are:

const (
	CommandSetLoopMode         CommandName  = "SetLoopMode"
	CommandChangeLoopInference CommandName  = "ChangeLoopInference"
	SetLoopModeAck             CommandField = "Ack"
	ChangeLoopInferenceAck     CommandField = "Ack"
)

type SetLoopMode struct {
	Header
	Mode string                  `json:"mode,omitzero"`
	Ack  chan<- LoopChangeResult `json:"-"`
}

type ChangeLoopInference struct {
	Header
	Model     model.Model             `json:"model,omitzero"`
	Effort    model.Effort            `json:"effort,omitzero"`
	SetModel  bool                    `json:"set_model,omitzero"`
	SetEffort bool                    `json:"set_effort,omitzero"`
	Ack       chan<- LoopChangeResult `json:"-"`
}

type LoopChangeResult struct {
	Err    error
	Mode   string
	Model  model.Model
	Effort model.Effort
}

These are control commands, not MarshalCommand arms. The durable records are event.LoopModeChanged and event.LoopInferenceChanged; restore folds those events to determine the next-turn configuration. The actor requires a non-nil, buffered Ack because it performs one non-blocking direct send. Validate on either command returns *command.InvalidCommandError for a nil channel and *command.UnbufferedAckError for an unbuffered channel.

Mode changes

The empty ModeName selects the base mode. Any non-empty mode must be declared by the loop definition. On success, the controller’s Handle.Mode() and Handle.Model() are updated from the actor’s committed result, not from a speculative client value. An unknown mode, a closing or exited loop, a durable append failure, or an invalid model/effort returns *loop.ChangeError and leaves the previous configuration in place.

func selectMode(ctx context.Context, c loop.Controller, mode loop.ModeName) error {
	if err := c.SetMode(ctx, mode); err != nil {
		var changeErr *loop.ChangeError
		if errors.As(err, &changeErr) {
			log.Printf("mode %q refused: %s", changeErr.Mode, changeErr.Kind)
		}
		return err
	}
	return nil
}

Model and effort changes

Use the currently reported model when changing only effort, or pass a validated replacement model together with an effort in one atomic batch:

func raiseEffort(ctx context.Context, c loop.Controller) error {
	current := c.Model()
	return c.Change(ctx,
		loop.ChangeModel(current),
		loop.ChangeEffort(model.EffortHigh),
	)
}

The batch does not partially apply. The actor validates the full model and effort before it emits LoopInferenceChanged. A live turn still uses its original runtime. The next turn resolves the new secret-free model descriptor and effort.

%%{init: {"theme":"dark"}}%%
sequenceDiagram
    participant App as Application
    participant C as loop.Controller
    participant L as Loop actor
    participant J as Journal

    App->>C: SetMode or Change
    C->>L: live control command + buffered Ack
    L->>L: validate complete request
    L->>J: append LoopModeChanged or LoopInferenceChanged
    J-->>L: durable success
    L-->>C: LoopChangeResult
    C-->>App: update Handle view and return
    Note over L: current turn unchanged; next turn uses committed values

Error categories and ownership

const (
	ChangeInvalidMode          ChangeErrorKind = "invalid_mode"
	ChangeInvalidModel         ChangeErrorKind = "invalid_model"
	ChangeInvalidEffort        ChangeErrorKind = "invalid_effort"
	ChangeNoChanges            ChangeErrorKind = "no_changes"
	ChangeLoopShuttingDown     ChangeErrorKind = "loop_shutting_down"
	ChangeLoopExited           ChangeErrorKind = "loop_exited"
	ChangeContextDone          ChangeErrorKind = "context_done"
	ChangeDurableAppendFailed  ChangeErrorKind = "durable_append_failed"
)

type ChangeError struct {
	Kind  ChangeErrorKind
	Mode  ModeName
	Tool  string
	Cause error
}

The source also defines external-tool-specific ChangeErrorKind values; those are covered on Install and remove Loop tools. Use errors.As and inspect Kind, never parse Error() text.

The public contract is pkg/loop/controller.go. The exact live-channel validation is proved by pkg/command/loop_change_test.go, and the actor boundary, atomicity, durable event, and next-turn behavior are exercised by the loop controller tests in the Harness runtime. These links are proofs of the behavior described here; applications still call the controller rather than constructing SetLoopMode themselves.

Source and proof

← back to documentation