Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Live Loop controllers

Address the active Loop and control exact live Loops.

developer

The session registry owns every loop handle for the session, including child loops created by delegation. ActiveLoop is a mutable default target for Submit; it is not a separate worker and changing it does not stop any loop.

Loop views

The data-plane methods have exact signatures:

ActiveLoop() loop.Handle
Loop(uuid.UUID) (loop.Handle, bool)
LoopController(uuid.UUID) (loop.Controller, bool)
SetActiveLoop(context.Context, uuid.UUID) error

The read-only handle is:

type Handle interface {
	ID() uuid.UUID
	Mode() ModeName
	Model() model.Model
}

Loop returns a registered handle even when an idle child remains in the registry. The boolean is false only when the UUID is not registered. A handle’s Mode and Model are updated from the actor’s committed change reply, so the view does not run ahead of durable state.

Active selection

SetActiveLoop serializes active-loop selection with shutdown. It refuses a missing ID, an exited actor, a closing session, or a faulted session. A change to a different loop publishes event.ActiveLoopChanged before changing the in-memory default. Re-selecting the current loop is a no-op.

func sendToFocused(ctx context.Context, c session.SessionController, blocks []content.Block) error {
	h := c.ActiveLoop()
	if h == nil {
		return fmt.Errorf("no active loop")
	}
	if _, err := c.SubmitToLoop(ctx, h.ID(), blocks); err != nil {
		return err
	}
	return nil
}

The focused UUID is sampled by the caller in this example. A later SetActiveLoop does not retarget the already-created command.

Controller ownership

The exact trusted loop mutation interface is:

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

SetMode and Change are actor operations at a turn boundary. A failed batch is not partially applied. Interrupt covers the selected loop and every loop below it in the delegation tree; use the session’s Interrupt for all live loops.

loopID := c.ActiveLoop().ID()
controller, ok := c.LoopController(loopID)
if !ok {
	return fmt.Errorf("loop %s is no longer registered", loopID)
}
if err := controller.SetMode(ctx, loop.ModeName("review")); err != nil {
	var change *loop.ChangeError
	if errors.As(err, &change) {
		return fmt.Errorf("mode change %s: %w", change.Kind, err)
	}
	return err
}

Loop lifetime

The session retains child handles after a completed agent turn. RunAgent is an internal orchestration helper and closes its event subscription, not the sub-loop. Shutdown snapshots every registered loop and sends each one a shutdown command before the final session cancellation. This is why callers must not assume that an idle handle can be garbage-collected independently.

%%{init: {"theme":"dark"}}%%
stateDiagram-v2
    [*] --> Registered: LoopStarted committed
    Registered --> Running: input admitted
    Running --> Idle: terminal event
    Idle --> Running: SubmitToLoop
    Registered --> Closing: Session.Shutdown snapshot
    Running --> Closing: command.Shutdown
    Idle --> Closing: command.Shutdown
    Closing --> Exited: actor DoneChan closes
    Exited --> [*]: session registry retained until teardown

Source and proof

← back to documentation