run

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 23, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package run is Chatwright's part-composition runtime: it executes an ordered sequence of Parts — deterministic scenario fragments and ai-goal actor-loop passages — over one shared Environment (one platform.Emulator, one cast, one continuous journal), exactly the shape spec/ideas/hybrid-runs.md describes: "a run is an ordered sequence of parts ... over one environment, one cast, one continuous journal."

A Run never duplicates journal content or re-runs mechanics that already exist elsewhere in this module: a deterministic Part executes an existing cw.Fragment via cw.InvokeFragment (provenance retained — see DeterministicOutcome); an ai-goal Part drives goal.CampaignState and actor.Loop exactly as the frozen campaign/bundle end-to-end tests do, scoped to that Part's own goal.Goal and goal.Budgets. Run.Execute captures each Part's journal boundary (per chat: first entry index + entry count) by snapshotting platform.Emulator.Journal before and after the Part runs — the "platform Journal seam" hybrid-runs.md calls for — and AssembleBundleRun (see bundle.go) turns the result into a sdk.Run, extending SingleAIGoalRun's single-part mapping to however many Parts actually ran, converting this runtime's internal types to the sdk's wire shapes along the way (see wire.go — the sdk owns every wire shape). A plain campaign is exactly a Run with one ai-goal Part, so the campaign execution path is fully expressible through this same layer, per hybrid-runs.md's MVP scope.

Every timestamp this package's own runtime state needs — the run ceiling's elapsed-duration check, and each ai-goal Part's goal.CampaignState/actor.Loop clock — comes from Environment.Now, never time.Now, mirroring the rest of this module's injected-clock convention (goal.NewCampaignState, actor.Config.Now, sdk.Metadata.CreatedAt).

Index

Constants

This section is empty.

Variables

View Source
var ErrNilClock = errors.New("run: Environment.Now is nil")

ErrNilClock means Environment.Now was nil.

View Source
var ErrNilEmulator = errors.New("run: Environment.Emulator is nil")

ErrNilEmulator means Environment.Emulator was nil.

View Source
var ErrNoChatIDs = errors.New("run: Environment.ChatIDs is empty")

ErrNoChatIDs means Environment.ChatIDs was empty. A Run cannot compute any Part's journal boundary without knowing which chats its journal spans — see Environment's own doc comment.

Functions

func AssembleBundleRun

func AssembleBundleRun(in AssembleBundleRunInput) sdk.Run

AssembleBundleRun builds an sdk.Run from a completed Run's Result — the multi-part counterpart to SingleAIGoalRun's single-part mapping. Only Parts that actually executed (PartCompleted, PartFailed or PartCeilingStopped — see in.Result.Parts) become sdk.Part entries, in the order they ran; a Part the Run never reached (PartAborted, PartCoverageGap — see in.Result.Skipped) produced no journal entries to bound and so is not represented in the persisted evidence at all — a caller that wants to report on why the Run stopped short reads Result.Skipped/Result.CeilingTrip directly, which AssembleBundleRun deliberately leaves outside the sdk.Run shape rather than inventing a new sdk.Part field for it (the wire schema is the sdk's to change, never this package's).

func SingleAIGoalRun

func SingleAIGoalRun(in SingleAIGoalRunInput) sdk.Run

SingleAIGoalRun builds an sdk.Run containing exactly one ai-goal Part whose JournalBoundary spans each chat's entire journal — the "plain campaign is a single ai-goal part" path the standard repository's spec/ideas/hybrid-runs.md MVP scope describes. It moved here from the old bundle package when the wire model split out to chatwright.dev/sdk: the sdk owns the shapes, this runtime owns the conversion from its own internal types (see wire.go). A future hybrid run assembles its Run via AssembleBundleRun instead, once more than one Part actually ran; this helper only ever emits one.

func WireJournal

func WireJournal(chatID int64, entries []platform.JournalEntry) sdk.ChatJournal

WireJournal packages one chat's journal — the entries returned by platform.Emulator.Journal — as the sdk.ChatJournal a run-bundle Run.Chats roster carries, converting each platform.JournalEntry to its sdk wire equivalent. It is the helper callers assembling a Run's Chats (for SingleAIGoalRun or AssembleBundleRun) build each per-chat entry with.

Types

type AIGoalPartInput

type AIGoalPartInput struct {
	// ActorID references the roster sdk.Actor that runs this Part's
	// loop — becomes sdk.AIGoalSection.ActorID verbatim.
	ActorID string
	// Goal is this Part's own goal.Goal, including its own goal.Budgets —
	// each ai-goal Part carries its own budgets, independent of any
	// Run.Ceiling (see RunCeiling).
	Goal goal.Goal
	// Provider proposes this Part's actions — any actor.Provider, including
	// actor.NewScriptedProvider for a zero-token, fully deterministic Part.
	Provider actor.Provider
	// Config configures the actor.Loop this Part runs. ChatID and User are
	// required (also used to construct this Part's own observe.Engine).
	// Config.Now is always overwritten with the owning Run's
	// Environment.Now before the loop starts — a caller-set value here is
	// never consulted, so it may be left nil.
	Config actor.Config
}

AIGoalPartInput is everything NewAIGoalPart needs to declare an ai-goal Part.

type AssembleBundleRunInput

type AssembleBundleRunInput struct {
	// RunID, Platform and EndpointProfile become sdk.Run's own fields —
	// see sdk.Run.ID/Platform/EndpointProfile.
	RunID           string
	Platform        string
	EndpointProfile string
	// Actors is the run's roster — see sdk.Run.Actors.
	Actors []sdk.Actor
	// Chats is the run's full, final per-chat journal — the same shape
	// SingleAIGoalRunInput.Chats expects (every entry the whole Run
	// produced, in each chat, from the very start); build each entry with
	// WireJournal.
	Chats []sdk.ChatJournal
	// Result is a completed Run.Execute's own return value.
	Result Result
	// Bookmarks and Annotations become the Run's own fields verbatim — see
	// sdk.Run.Bookmarks/Annotations.
	Bookmarks   []sdk.Bookmark
	Annotations []sdk.Annotation
}

AssembleBundleRunInput is everything AssembleBundleRun needs to build an sdk.Run from a completed Run's Result.

type CeilingTrip

type CeilingTrip struct {
	Reason goal.StopReason
	PartID string
}

CeilingTrip attributes a RunCeiling trip to both the aggregate reason and the Part that was executing when it fired — hybrid-runs.md's "when the ceiling trips mid-part, the stop reason must attribute both the run ceiling and the part it tripped in". Reason reuses goal.StopReason's own budget vocabulary (StopBudgetSteps, StopBudgetCost, StopBudgetDuration) rather than a parallel type, since a run-level ceiling trip is the same kind of budget exhaustion goal.CampaignState already models per Part, just aggregated across Parts.

type DeterministicOutcome

type DeterministicOutcome struct {
	Definition  cw.Definition
	Steps       []cw.StepEvidence
	Checkpoints []cw.CheckpointEvidence
	Branches    []cw.BranchEvidence
	Failures    []cw.FailureEvidence
}

DeterministicOutcome is a deterministic Part's retained provenance: the cw.FragmentInvocation evidence cw.InvokeFragment produced, carried through unconverted so nothing this package's own execution invented can be mistaken for evidence the fragment itself recorded.

type Environment

type Environment struct {
	// Emulator is the shared platform.Emulator every Part acts against.
	Emulator platform.Emulator
	// ChatIDs is every chat this run's journal spans, in the order boundary
	// entries are reported. Must be non-empty — see ErrNoChatIDs.
	ChatIDs []int64
	// Now supplies this run's notion of the current time — see the package
	// doc comment for why this is never time.Now internally. Must not be
	// nil — see ErrNilClock.
	Now func() time.Time
}

Environment is the one platform.Emulator — plus its declared chat IDs and its injected clock — every Part in a Run executes over: the "one environment, one cast, one continuous journal" spec/ideas/hybrid-runs.md requires. ChatIDs names every chat this run's journal spans; Run.Execute snapshots each of them (via Emulator.Journal) immediately before and after every Part to compute that Part's sdk.JournalBoundary (see diffBoundary), so a chat a given Part never touches at all contributes no boundary entry for that Part. A Run never infers ChatIDs on its own — deterministic Parts execute an opaque cw.Fragment closure this package cannot introspect, so there is no way to discover which chats it touched short of the caller declaring them up front.

type FailurePolicy

type FailurePolicy string

FailurePolicy declares what a Run does when one Part's own execution fails (PartFailed — see PartStatus): abort the whole Run, or mark every subsequent Part a coverage gap (PartCoverageGap) without executing it. hybrid-runs.md frames this around a failed deterministic Part specifically ("a failed deterministic part may abort the run or mark subsequent dependent parts as coverage gaps"); this package applies the same mechanism uniformly to an ai-goal Part's own hard runtime error too, for consistency — a genuinely deliberate generalisation, not scope creep, since nothing in hybrid-runs.md restricts it to one Part kind.

The zero value ("") behaves exactly like FailurePolicyAbort — see effective. This is a deliberate, documented choice, not an accidental default: silently downgrading a hard failure into "the rest of the run continues, and everything after is an acknowledged gap" must be opted into explicitly (FailurePolicyCoverageGap); an unset FailurePolicy never silently changes what a Run does.

const (
	// FailurePolicyAbort stops the Run immediately: no subsequent Part
	// executes, and none is recorded as a coverage gap either — see
	// PartAborted.
	FailurePolicyAbort FailurePolicy = "abort"
	// FailurePolicyCoverageGap marks every subsequent Part PartCoverageGap
	// (never executed) instead of aborting outright.
	FailurePolicyCoverageGap FailurePolicy = "coverage-gap"
)

Failure policies. See FailurePolicy.

type Part

type Part struct {
	// ID is caller-supplied and only needs to be unique within its Run —
	// mirrors sdk.Part.ID, since AssembleBundleRun copies it there
	// verbatim for every Part that actually executed.
	ID string
	// Title is an optional human-readable label — see sdk.Part.Title.
	Title string
	// Kind discriminates this Part's payload — sdk.PartKindDeterministic
	// or sdk.PartKindAIGoal, reusing the sdk's own wire vocabulary rather
	// than a parallel enum.
	Kind sdk.PartKind
	// FailurePolicy declares what happens to the rest of the Run if this
	// Part fails — see FailurePolicy.
	FailurePolicy FailurePolicy
	// contains filtered or unexported fields
}

Part is one declared passage of a Run: an id, a title, a kind-scoped payload built by NewDeterministicPart or NewAIGoalPart, and a FailurePolicy. The zero Part (constructed by hand rather than through either builder) has neither payload set; Run.Execute reports a clear error for it rather than silently doing nothing.

func NewAIGoalPart

func NewAIGoalPart(id, title string, policy FailurePolicy, in AIGoalPartInput) Part

NewAIGoalPart declares a Part whose ai-goal passage drives in.Provider through a fresh goal.CampaignState/actor.Loop pair for in.Goal — the same mechanism the frozen campaign/bundle end-to-end tests drive directly, scoped to one Part of a larger Run. policy governs what happens to the rest of the Run if this Part's loop returns an unexpected error (not a budget stop or a task simply failing/blocking — those are ordinary, evidenced outcomes visible in the Part's assembled Report, never PartFailed) — see FailurePolicy.

func NewDeterministicPart

func NewDeterministicPart[T any](id, title string, policy FailurePolicy, fragment cw.Fragment[T], inputs cw.EffectiveInputs[T]) Part

NewDeterministicPart declares a Part whose deterministic passage executes fragment (with the given effective inputs) via cw.InvokeFragment against the Run's own root *cw.ExecutionContext when Run.Execute reaches it — the existing scenario-composition contract, provenance retained (see DeterministicOutcome), not a parallel mechanism.

fragment.Execute drives the Run's shared Environment however the fragment needs to — typically the raw platform.Emulator primitives (SubmitText, SubmitClick, WaitForMessage, WaitForEdit — the same seam actor.Loop's own Actuator uses) rather than a testing.TB-bound cw.Chat, since a composed Run has no *testing.T of its own to hand a Chat and must report failure by returning an error, not by calling t.Fatalf. Fragment[T]'s signature (func(*ExecutionContext, T) error) carries no environment reference of its own, so the emulator/chat identity fragment.Execute acts against must be closed over from the call site, exactly like example_test.go's greetScenario closes over a *cw.Chatwright.

policy governs what happens to the rest of the Run if this Part's fragment returns an error — see FailurePolicy.

type PartOutcome

type PartOutcome struct {
	PartID string
	Title  string
	Kind   sdk.PartKind
	Status PartStatus
	// Err is set when Status is PartFailed: the deterministic Fragment's
	// own error, or the ai-goal Loop.RunTask's own error.
	Err error
	// Boundary is this Part's slice of the run-level journal, computed by
	// diffBoundary from Environment snapshots taken immediately before and
	// after the Part ran.
	Boundary sdk.JournalBoundary
	// Deterministic is set for a deterministic Part: the retained
	// cw.FragmentInvocation provenance (definition, steps,
	// checkpoints, branches, failures) — see DeterministicOutcome.
	Deterministic *DeterministicOutcome
	// AIGoal is set for an ai-goal Part that at least started running (any
	// status except one the Run never reached): the same shape
	// sdk.AIGoalSection carries, ready to attach to a sdk.Part
	// verbatim.
	AIGoal *sdk.AIGoalSection
	// CeilingTrip is set exactly on the one PartOutcome where Run.Ceiling
	// tripped (Status will be PartCeilingStopped), nil otherwise.
	CeilingTrip *CeilingTrip
}

PartOutcome is one executed Part's complete result.

type PartStatus

type PartStatus string

PartStatus is one declared Part's outcome once a Run has finished (or stopped short of reaching it). It is a string type, not an int enum, so it marshals to human-readable JSON if a caller chooses to persist a Result, matching AGENTS.md's JSON-artefact convention.

const (
	// PartCompleted: the Part executed and produced no error of its own.
	// For an ai-goal Part this says nothing about whether every task
	// succeeded — read AIGoalSection.Report for that — only that the loop
	// ran to a stop without a hard runtime error and the run ceiling never
	// tripped inside it.
	PartCompleted PartStatus = "completed"
	// PartFailed: the Part executed but its own mechanism reported a
	// failure — a deterministic Part's cw.Fragment.Execute returned
	// a non-nil error, or an ai-goal Part's actor.Loop.RunTask returned an
	// unexpected error. See PartOutcome.Err.
	PartFailed PartStatus = "failed"
	// PartCeilingStopped: an ai-goal Part was still executing when the
	// Run's RunCeiling tripped; the Part's own goal.CampaignState never
	// itself stopped. See PartOutcome.CeilingTrip.
	PartCeilingStopped PartStatus = "ceiling-stopped"
	// PartAborted: the Part never executed at all — a prior Part's
	// FailurePolicyAbort, or a RunCeiling trip (which always aborts,
	// regardless of any Part's FailurePolicy — see RunCeiling), stopped the
	// Run before it was reached.
	PartAborted PartStatus = "aborted"
	// PartCoverageGap: the Part never executed at all — a prior
	// deterministic Part failed under FailurePolicyCoverageGap. Distinct
	// from PartAborted so a caller can tell "the run gave up entirely" from
	// "this specific passage is an acknowledged coverage gap, opted into by
	// the failed part's own FailurePolicy".
	PartCoverageGap PartStatus = "coverage-gap"
)

Part statuses. See PartStatus.

type Result

type Result struct {
	// RunID mirrors Run.ID.
	RunID string
	// Parts is every Part that actually executed (PartCompleted,
	// PartFailed or PartCeilingStopped — see PartStatus), in the order it
	// ran. AssembleBundleRun turns this directly into a sdk.Run's Parts.
	Parts []PartOutcome
	// Skipped is every Part the Run never reached (PartAborted or
	// PartCoverageGap), in declared order, once execution stopped short.
	Skipped []SkippedPart
	// CeilingTrip is set once Ceiling ever tripped during this Run, nil
	// otherwise — mirrors whichever PartOutcome.CeilingTrip actually
	// tripped it, kept at Result level too so a caller never has to scan
	// Parts to learn whether the run-level ceiling fired at all.
	CeilingTrip *CeilingTrip
}

Result is everything Run.Execute produced: every Part that actually ran, in order, plus every Part the Run stopped short of reaching.

type Run

type Run struct {
	// ID identifies this Run — required (cw.NewExecutionContext,
	// which threads deterministic-Part provenance across the whole Run,
	// needs a non-empty root path/definition name) and, conventionally, the
	// same value later given to sdk.Run.ID when assembling evidence.
	ID string
	// Environment is the one platform.Emulator (plus its declared chat IDs
	// and clock) every Part executes over — see Environment.
	Environment Environment
	// Parts is this Run's ordered passages — see Part, NewDeterministicPart,
	// NewAIGoalPart.
	Parts []Part
	// Ceiling optionally aggregates ai-goal step/cost/duration usage across
	// every Part in the Run, on top of each ai-goal Part's own
	// goal.Budgets — see RunCeiling. The zero value means "no run-level
	// ceiling", mirroring goal.Budgets' own "zero means unlimited"
	// convention.
	Ceiling RunCeiling
}

Run is an ordered sequence of Parts executing over one Environment — the canonical "run" of docs/glossary.md. Execute runs every Part in order (see Execute) and returns a Result describing what happened to each one.

func (Run) Execute

func (r Run) Execute(ctx context.Context) (Result, error)

Execute runs every declared Part in order over r.Environment: deterministic Parts via cw.InvokeFragment, ai-goal Parts via goal.CampaignState and actor.Loop, in the same sequence and against the same shared journal — see the package doc comment. It stops the Run early (recording every remaining Part in Result.Skipped) when a Part fails and its FailurePolicy says to (FailurePolicyAbort or FailurePolicyCoverageGap — see Part. FailurePolicy), or when r.Ceiling trips (which always halts the Run, regardless of any Part's FailurePolicy).

The returned error is reserved for a Run-level configuration problem (a nil Environment field, an empty/duplicate Part ID, an ai-goal Part referencing a chat Environment.ChatIDs never declared, or a constructor failure — goal.NewCampaignState/actor.NewLoop rejecting a malformed Goal/Config) — never for a Part's own execution failure, which is recorded as PartFailed in the returned Result instead. On such an error, the returned Result still carries every Part that completed before the problem was found.

type RunCeiling

type RunCeiling struct {
	// MaxSteps caps the total number of actor.LoopEvents recorded across
	// every ai-goal Part in the Run.
	MaxSteps int
	// MaxCost caps the total accrued actor.Usage.Cost across every ai-goal
	// Part in the Run. Nil means cost is not budgeted at the run level,
	// mirroring goal.Budgets.MaxCost.
	MaxCost *float64
	// MaxDuration caps wall-clock time elapsed (per Environment.Now) since
	// Run.Execute started, checked between an ai-goal Part's tasks.
	MaxDuration time.Duration
}

RunCeiling optionally aggregates ai-goal usage across every Part in a Run, on top of each ai-goal Part's own goal.Budgets — hybrid-runs.md's "a run-level ceiling aggregates" across parts. Every field's zero value means "no limit", mirroring goal.Budgets' own convention, so the zero RunCeiling (Run's own default) means no run-level ceiling at all: every Part runs bounded only by its own goal.Budgets.

Only ai-goal Parts contribute to a RunCeiling's steps/cost accounting — deterministic Parts have no goal.Budgets-shaped notion of "a step" or "a cost" to aggregate. MaxDuration is checked at the same points (between an ai-goal Part's tasks) rather than continuously against wall-clock time throughout the whole Run, including any deterministic Parts' own duration — a deliberate simplification: see ceilingTracker.record and Run.runAIGoal's own doc comment on why "between tasks" is this package's finest checkpoint.

type SingleAIGoalRunInput

type SingleAIGoalRunInput struct {
	// RunID is caller-supplied — see sdk.Run.ID.
	RunID string
	// Platform is the platform name the run drove — see sdk.Run.Platform.
	Platform string
	// EndpointProfile is the run's declared endpoint profile — see
	// sdk.Run.EndpointProfile, sdk.EndpointProfilePlatformEmulated.
	EndpointProfile string
	// Actors is the run's roster — see sdk.Run.Actors.
	Actors []sdk.Actor
	// Chats is the run's continuous per-chat journal — see sdk.Run.Chats;
	// build each entry with WireJournal from the emulator's own Journal.
	Chats []sdk.ChatJournal

	// PartID and PartTitle name the single ai-goal Part this run gets — see
	// sdk.Part.ID, sdk.Part.Title.
	PartID    string
	PartTitle string

	// ActorID references the Actors entry that ran the loop — see
	// sdk.AIGoalSection.ActorID.
	ActorID string

	// Goal is the goal.Goal the part's actor loop ran; it becomes the
	// part's sdk.AIGoalSection.Goal.
	Goal goal.Goal
	// Events is every actor.LoopEvent the loop recorded (actor.Loop.Events),
	// in the loop's own order; it becomes sdk.AIGoalSection.Events.
	Events []actor.LoopEvent
	// Observations is the raw retained-observation map exactly as
	// actor.Loop.Observations returns it. SingleAIGoalRun sorts it ascending
	// by Sequence and converts it internally — absorbing the pre-split
	// bundle package's SortObservations — so a caller never orders (or
	// wire-converts) observations by hand. See sdk.AIGoalSection.Observations
	// for why the stored form is an ordered slice, not this map.
	Observations map[int64]observe.Observation
	// Report is the part's assembled campaign.Report (campaign.Assemble); it
	// becomes sdk.AIGoalSection.Report.
	Report campaign.Report
	// Evidence is the datastate.Evidence any data-state assertions produced,
	// in the order they were run; it becomes sdk.AIGoalSection.Evidence.
	Evidence []datastate.Evidence

	// Bookmarks and Annotations become the Run's own fields verbatim — see
	// sdk.Run.Bookmarks, sdk.Run.Annotations. Both are optional: a caller
	// with nothing to attach leaves them nil, and the resulting Run carries
	// none (SingleAIGoalRun never invents one).
	Bookmarks   []sdk.Bookmark
	Annotations []sdk.Annotation
}

SingleAIGoalRunInput is everything SingleAIGoalRun needs to assemble a single-part, single-run sdk.Run from a plain campaign's own pieces. The campaign-produced fields (Goal, Events, Observations, Report, Evidence) take this runtime's own types — SingleAIGoalRun converts them to the sdk's wire shapes internally (see wire.go); the roster fields (Actors, Chats, Bookmarks, Annotations) are wire-typed already, since a roster is a bundle-only concept with no runtime counterpart — callers build each Chats entry with WireJournal.

type SkippedPart

type SkippedPart struct {
	PartID string
	Status PartStatus // PartAborted or PartCoverageGap.
}

SkippedPart names one declared Part the Run never executed, and why.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL