Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Overview

Understand the conceptual model request and tool boundary repeated within a Harness Turn.

developer

A Step is one model request/response cycle inside a Turn. It is a conceptual boundary, not a public Step type or package. A cycle may produce an assistant message with tool uses, execute the admitted tool call batch, append tool-result messages, and then let the enclosing Turn start another Step. A text-only assistant message ends the Turn.

The public API deliberately splits the boundary into observation and durable records:

NeedPublic surfaceWhat it tells you
Observe the boundary in processhook.OperationStep, hook.StepData, hook.StepIndexThe step-local index and full operation coordinates in a hook snapshot.
Render live model outputevent.TokenDeltaOne ephemeral content.Chunk from the active Step.
Read committed Step historyevent.StepDoneThe finalized assistant message followed by its tool-result messages.
Correlate all Step recordsevent.Header.CoordinatesSessionID, LoopID, TurnID, and StepID; Step events require all four IDs.

There is no separate public per-Step start, failure, or completion event. A failed or interrupted in-flight Step is represented by the enclosing Turn terminal, and it has no StepDone record.

How it works

Harness builds the request from the committed loop history plus the Turn’s staged messages. It starts the Step hook, starts the inference hook, and streams chunks. The runtime materializes exactly one assistant message. If that message contains tool uses, the runtime prepares and authorizes the calls, executes the admitted batch, appends one content.ToolResultMessage for each result, and commits the complete group. The actor publishes StepDone only at that commit point.

%%{init: {"theme":"dark"}}%%
sequenceDiagram
    participant A as loop actor
    participant T as Turn goroutine
    participant M as inference client
    participant X as tool runtime
    participant J as event/journal boundary

    A->>A: commit initial user message
    T->>T: hook.OperationStep
    T->>M: request for this Step
    loop each stream chunk
        M-->>T: content.Chunk
        T-->>J: event.TokenDelta (ephemeral)
    end
    M-->>T: end of stream
    alt tool uses present
        T->>X: authorize and execute batch
        X-->>T: tool results
        T->>J: commit StepDone (AI + tool results)
        J-->>T: commit acknowledgement
        T->>T: next Step in same Turn
    else no tool uses
        T->>J: commit StepDone (AI only)
        J-->>T: commit acknowledgement
        T->>J: TurnDone
    end

Observe a Step

// ObserveStepEvents subscribes before submitting so the live fan-in cannot
// race past the first enduring event. The caller owns session construction.
package example

import (
	"context"
	"fmt"

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

func observeStepEvents(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, nil)
	if err != nil {
		return fmt.Errorf("submit: %w", err)
	}
	fmt.Println("submitted", inputID)
	for delivery := range sub.Events() {
		switch e := delivery.Event.(type) {
		case event.TokenDelta:
			fmt.Printf("turn=%d step=%v chunk=%T\n", e.TurnIndex, e.StepID, e.Chunk)
		case event.StepDone:
			fmt.Printf("committed step=%v messages=%d\n", e.StepID, len(e.Messages))
		case event.TurnDone, event.TurnFailed, event.TurnInterrupted:
			return nil
		}
	}
	return fmt.Errorf("event subscription closed: %v", sub.Err())
}

event.EventFilter chooses loop producers separately for ephemeral and enduring events. TokenDelta is ephemeral, so a filter that only asks for enduring events will still receive StepDone and terminal events but not live chunks. SubscribeEvents has no replay; subscribe before Submit when the first event matters.

Step boundaries

hook.StepData.Index is zero-based within its Turn. The event stream does not carry a public step-index field. Use StepID in the event header to correlate TokenDelta, tool/gate events, and StepDone; use hook.StepData.Index when a hook needs the ordinal. event.TokenDelta.TurnIndex is the parent Turn counter, not the Step index.

The durable rule is simple: StepDone means the complete group was accepted at the actor-owned commit boundary. A live TokenDelta can be dropped without making the journal incomplete because the later StepDone is authoritative.

Source and proof

← back to documentation