Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Overview

Follow one admitted user input from TurnStarted to an exact durable terminal event.

developer

A Turn is one bounded execution started by one user input after admission. It contains one or more steps, each a conceptual Step boundary, until the model returns a final answer, the runtime interrupts, or the Turn fails. A Turn is a concept, not a public Turn type or package.

Lifecycle

Session.Submit and Session.SubmitToLoop are fire-and-forget admission calls. They return a command ID only after the command is handed to a live loop. event.TurnStarted is the first enduring Turn event and carries that command ID in Header.Cause.CommandID. Tool-use Steps can repeat inside the same Turn. The Turn ends with exactly one of TurnDone, TurnFailed, or TurnInterrupted.

%%{init: {"theme":"dark"}}%%
stateDiagram-v2
    [*] --> Admission
    Admission --> Queued: busy loop
    Admission --> Started: idle loop
    Queued --> Started: later start
    Started --> Step: TurnStarted committed
    Step --> Step: tool uses and continuation
    Step --> Done: final assistant message committed
    Step --> Failed: non-cancellation error
    Step --> Interrupted: Turn context canceled
    Done --> [*]
    Failed --> [*]
    Interrupted --> [*]

event.TurnIndex is a loop-local counter carried by Turn and stream events. It is not globally unique across loops; pair it with Header.Coordinates.LoopID and TurnID. The runtime tests show a newly constructed loop’s first Turn is index 1 and a restored loop’s next Turn is its persisted index plus one. hook.TurnData.Index carries the same Turn index to an in-process Turn hook.

Observe turns

package example

import (
	"context"
	"fmt"

	"github.com/looprig/core/content"
	"github.com/looprig/harness/pkg/event"
	"github.com/looprig/harness/pkg/session"
)

func waitForTurn(ctx context.Context, live session.Session) error {
	sub, err := live.SubscribeEvents(event.EventFilter{
		Ephemeral: event.LoopScope{All: true},
		Enduring:  event.LoopScope{All: true},
	})
	if err != nil {
		return fmt.Errorf("subscribe: %w", err)
	}
	defer sub.Close()

	inputID, err := live.Submit(ctx, []content.Block{
		&content.TextBlock{Text: "Summarize the project"},
	})
	if err != nil {
		return fmt.Errorf("submit: %w", err)
	}
	for delivery := range sub.Events() {
		if reply, ok := delivery.Event.(event.Reply); ok && reply.ReplyTo() == inputID {
			fmt.Printf("input %v resolved by %T\n", inputID, delivery.Event)
		}
		switch delivery.Event.(type) {
		case event.TurnDone:
			return nil
		case event.TurnFailed, event.TurnInterrupted:
			return fmt.Errorf("turn ended with %T", delivery.Event)
		}
	}
	return fmt.Errorf("event subscription closed: %v", sub.Err())
}

Subscribe before submitting because the live hub does not replay an event to a later subscriber. A successful Submit does not mean a Turn started or completed. The returned ID is only the correlation ID for the eventual reply event.

Terminal events

The terminal set is closed and exact:

EventMeaningDurable fields
event.TurnDoneFinal assistant response committed successfully.TurnIndex, complete Message, checked aggregate Usage.
event.TurnFailedNon-cancellation failure.TurnIndex, typed in-memory Err that is projected by the wire codec.
event.TurnInterruptedTurn context was canceled.TurnIndex; no error payload.

All three are Enduring, terminal, loop-scoped events. They report EndsTurn() == true. The terminal set is closed; use event type switches, not names inferred from UI state.

Source and proof

← back to documentation