actor

package
v0.1.1 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: 14 Imported by: 0

Documentation

Overview

Package actor is Chatwright's AI actor loop: the observe-plan-act-validate cycle that drives a goal.CampaignState through a conversation using a pluggable Provider.

The seam to any concrete model or vendor is exactly one interface — Provider — kept deliberately dumb: it reads a Prompt (goal/task context, the current observe.Observation, bounded recent history) and returns a Proposal. Every safety property lives in the loop, never in a Provider: budgets and stop reasons come from goal.CampaignState, click proposals are checked with observe.Engine.Validate, and Chatwright remains authoritative — an invalid proposal is recorded and re-prompted, never silently acted on. See Loop.

Index

Constants

This section is empty.

Variables

View Source
var ErrCassetteCacheMiss = errors.New("actor: replay cache miss")

ErrCassetteCacheMiss means a ModeReplay CassetteProvider was asked to propose for a prompt its cassette has no recorded entry for.

View Source
var ErrNilClock = errors.New("actor: clock function is nil")

ErrNilClock means Config.Now was nil.

View Source
var ErrScriptExhausted = errors.New("actor: scripted provider's script is exhausted")

ErrScriptExhausted means a ScriptedProvider's Propose was called more times than its script has entries.

Functions

This section is empty.

Types

type ActionOutcome

type ActionOutcome struct {
	Kind ActionOutcomeKind `json:"kind"`
	// Detail is a human-readable explanation, set for
	// ActionSkippedInvalid/ActionResolutionFailed (why), empty otherwise.
	Detail string `json:"detail"`
}

ActionOutcome is what actually happened when the loop tried to act on a Proposal.

type ActionOutcomeKind

type ActionOutcomeKind string

ActionOutcomeKind classifies what happened when the loop acted on a proposal, or why it did not act at all. It is a string type, not an int enum, so it marshals to human-readable JSON (see AGENTS.md's "JSON artefacts carry human-readable string constants" convention) rather than a bare, meaningless integer.

const (
	// ActionSkippedInvalid: the proposal failed validation (a stale click)
	// or was malformed; the loop never submitted anything to the platform.
	ActionSkippedInvalid ActionOutcomeKind = "skipped-invalid"
	// ActionExecuted: the proposed action was submitted to the platform and
	// produced an observable change (a new message, an edit, or an
	// actions-changed update).
	ActionExecuted ActionOutcomeKind = "executed"
	// ActionExecutedNoEffect: the proposed action was submitted, but the
	// next observation showed no change at all.
	ActionExecutedNoEffect ActionOutcomeKind = "executed-no-effect"
	// ActionResolutionFailed: a freshly validated proposal that the loop
	// could not resolve to a concrete platform action — e.g. no button on
	// the current message carries the validated action's label (see Loop's
	// single-live-surface scoping note). This counts as a task failure
	// (goal.CampaignState.RecordFailure).
	ActionResolutionFailed ActionOutcomeKind = "resolution-failed"
	// ActionTaskCompleted: a ProposeTaskDone proposal was accepted;
	// goal.CampaignState.Complete was called for the task.
	ActionTaskCompleted ActionOutcomeKind = "task-completed"
	// ActionTaskGivenUp: a ProposeGiveUp proposal was accepted;
	// goal.CampaignState.Fail was called for the task.
	ActionTaskGivenUp ActionOutcomeKind = "task-given-up"
)

Action outcome kinds. See ActionOutcome.

func (ActionOutcomeKind) String

func (k ActionOutcomeKind) String() string

String renders k for diagnostics, test failure messages and reports.

type Actuator

type Actuator interface {
	SubmitText(chatID int64, user platform.User, text string) error
	SubmitClick(chatID int64, user platform.User, data string, targetMessageID int) error
	WaitForMessage(chatID int64, consumed int, timeout time.Duration) (*platform.Message, bool)
	WaitForEdit(chatID int64, messageID int, afterVersion int, timeout time.Duration) (*platform.Message, bool)
}

Actuator is the narrow seam the loop acts through: exactly the subset of platform.Emulator needed to submit a user action and read back the bot's raw reply. platform.Emulator satisfies it directly. This is deliberately the only place in this package that ever sees a platform-native message ID or callback datum — a Provider never does (see observe's doctrine that actors receive only the semantic Observation surface, never raw platform payloads).

type Cassette

type Cassette struct {
	// ProviderConfig is a free-form, caller-supplied description of the
	// wrapped provider's configuration (model, system prompt, temperature,
	// ...), folded into every entry's key so replaying against a
	// differently configured provider is a cache miss, not a silent
	// mismatch.
	ProviderConfig string `json:"providerConfig"`

	Entries []CassetteEntry `json:"entries"`
}

Cassette is a JSON-serialisable, ordered record of Provider interactions, keyed by a deterministic hash of the provider configuration plus the canonical prompt content — see NewCassetteProvider. It is meant to be checked into the repository under testdata/cassettes/ as human-readable, reviewable JSON; it never carries provider auth (that lives outside the prompt entirely).

func LoadCassette

func LoadCassette(path string) (*Cassette, error)

LoadCassette reads a Cassette from a JSON file at path.

func NewCassette

func NewCassette(providerConfig string) *Cassette

NewCassette returns an empty Cassette for providerConfig, ready to record into.

func (*Cassette) Save

func (c *Cassette) Save(path string) error

Save writes c to path as indented, human-readable JSON, creating path's parent directory if needed.

type CassetteEntry

type CassetteEntry struct {
	// Key is the deterministic hash of ProviderConfig plus the canonical
	// prompt this entry was recorded for; see promptKey.
	Key string `json:"key"`
	// PromptSummary is a short, human-readable description of the prompt —
	// for PR review and for ErrCassetteCacheMiss's error message. It is not
	// itself used to look the entry up (Key is).
	PromptSummary string   `json:"promptSummary"`
	Proposal      Proposal `json:"proposal"`
	Usage         Usage    `json:"usage"`
}

CassetteEntry is one recorded Propose call.

type CassetteProvider

type CassetteProvider struct {
	// contains filtered or unexported fields
}

CassetteProvider decorates any Provider with record/replay determinism (see Mode) — any Provider is recordable simply by wrapping it.

func NewCassetteProvider

func NewCassetteProvider(mode Mode, wrapped Provider, cassette *Cassette) (*CassetteProvider, error)

NewCassetteProvider wraps wrapped with cassette in mode:

  • ModeLive: wrapped must be non-nil; Propose calls it directly, no cassette I/O.
  • ModeRecord: wrapped must be non-nil; every Propose call invokes it and appends the outcome to cassette. Call Cassette then Cassette.Save afterwards to persist it.
  • ModeReplay: wrapped may be nil — it is never called. A cache miss returns an error wrapping ErrCassetteCacheMiss, carrying the missing prompt's summary, never a live call.

cassette must not be nil; use NewCassette for a fresh one or LoadCassette to replay a checked-in one.

func (*CassetteProvider) Cassette

func (p *CassetteProvider) Cassette() *Cassette

Cassette returns the provider's underlying Cassette — after a ModeRecord session, pass it to Cassette.Save to persist what was recorded.

func (*CassetteProvider) Propose

func (p *CassetteProvider) Propose(ctx context.Context, prompt Prompt) (Proposal, Usage, error)

Propose implements Provider per p's configured Mode.

type Config

type Config struct {
	// ChatID and User identify the conversation the loop drives, exactly as
	// passed to Actuator.SubmitText/SubmitClick.
	ChatID int64
	User   platform.User

	// HistoryWindow bounds how many recent LoopEvents are fed to each
	// Prompt.History. Defaults to 10 if <= 0.
	HistoryWindow int
	// NonProgressLimit is how many consecutive invalid-or-no-effect
	// proposals the loop tolerates for one task before stopping the
	// campaign itself (via goal.CampaignState.Abort) rather than looping
	// forever. Defaults to 3 if <= 0.
	NonProgressLimit int
	// ActWaitTimeout bounds how long the loop waits, after acting, for the
	// platform's raw reply (WaitForMessage/WaitForEdit) that the journal
	// read (observe.Engine.Observe) already showed exists. Defaults to 5s
	// if <= 0.
	ActWaitTimeout time.Duration

	// Now supplies the loop's notion of the current time, stamped onto
	// every LoopEvent.At. Must not be nil; pass the same clock given to the
	// Loop's goal.CampaignState so timestamps and budget decisions agree.
	Now func() time.Time

	// DisableObservationRetention turns off the Loop's retention of every
	// observe.Observation it produces (see Loop.Observations). Retention is
	// ON by default (the zero value is false) — a campaign's entire purpose
	// is producing evidence, and the retained observation bodies are what
	// let a run bundle (chatwright.dev/sdk's Bundle) stay self-contained: a
	// player can show exactly
	// what the actor saw at each step without re-deriving it from a
	// transcript. Set this true only when a campaign is long enough, or
	// memory-bounded enough, that holding every Observation body for its
	// whole run is not affordable; Loop.Events (and campaign.Report) are
	// unaffected either way, since neither depends on retention.
	DisableObservationRetention bool
}

Config configures a Loop.

type Loop

type Loop struct {
	// contains filtered or unexported fields
}

Loop drives goal.CampaignState's tasks through the observe-plan-act-validate cycle: observe (observe.Engine.Observe), plan (Provider.Propose), validate (observe.Engine.Validate for clicks, plus CampaignState's own guarded transitions), act (Actuator), record (append a LoopEvent). Budgets and stop reasons are enforced entirely through the injected goal.CampaignState — see RunTask.

A Loop is not safe for concurrent use: drive one campaign from one goroutine (see the "Out of scope: parallel actors" note in the slice-2 plan).

func NewLoop

func NewLoop(provider Provider, engine *observe.Engine, actuator Actuator, campaign *goal.CampaignState, goalDef goal.Goal, cfg Config) (*Loop, error)

NewLoop constructs a Loop. campaign must have been created from goalDef (NewLoop does not itself validate that, but task lookups assume it).

func (*Loop) Events

func (l *Loop) Events() []LoopEvent

Events returns a detached copy of every LoopEvent recorded so far, across every task this Loop has run.

func (*Loop) Observations

func (l *Loop) Observations() map[int64]observe.Observation

Observations returns a detached copy of every observe.Observation this Loop has produced so far (every observe.Engine.Observe call observeAndSync made — including the post-action re-observation observedEffect performs, not only the ones a LoopEvent.ObservationSequence points at), keyed by its Sequence. It is always empty, never nil, so a caller can range over it unconditionally — either because nothing has been observed yet, or because Config.DisableObservationRetention is true.

func (*Loop) RunCampaign

func (l *Loop) RunCampaign(ctx context.Context) ([]TaskResult, error)

RunCampaign repeatedly runs RunTask for every eligible task (in goalDef's declared order — see Loop's own non-progress/budget guards for why this is safe to do unattended) until no task is eligible or the campaign has stopped.

func (*Loop) RunTask

func (l *Loop) RunTask(ctx context.Context, taskID string) (TaskResult, error)

RunTask activates taskID (if it is currently Pending and eligible; a resumed Active task is driven as-is) and runs the observe-plan-act-validate cycle until the task reaches a terminal status, the campaign stops for any reason (a budget, goal-complete from another path, cancellation, error), or the loop's own non-progress detection fires.

type LoopEvent

type LoopEvent struct {
	// Index is 0-based and monotonic across one Loop's lifetime (not just
	// one task), so it is stable to reference from a campaign.Finding.
	Index int `json:"index"`
	// At is stamped from the loop's injected clock (Config.Now), never
	// time.Now, so a run's timeline is reproducible.
	At time.Time `json:"at"`
	// TaskID is the task this iteration was attempting.
	TaskID string `json:"taskId"`

	// ObservationSequence is the observe.Observation.Sequence this
	// iteration observed before proposing — the same value a
	// campaign.Finding's evidence links back to.
	ObservationSequence int64 `json:"observationSequence"`

	Proposal Proposal `json:"proposal"`
	Usage    Usage    `json:"usage"`

	// Validation is the loop's validate-step outcome for Proposal. It is
	// only Checked for ProposeClick — the loop has nothing to validate
	// against observe for a send-text, task-done or give-up proposal.
	Validation ValidationOutcome `json:"validation"`

	// Action is what actually happened when the loop tried to act on
	// Proposal (or why it did not).
	Action ActionOutcome `json:"action"`
}

LoopEvent is one loop iteration's complete structured record: what was observed, what was proposed, how the proposal validated, what happened when the loop acted on it (or chose not to), and what it cost. LoopEvents are the loop's entire raw material for campaign.Report — nothing the report needs is reconstructed after the fact from logs or a transcript.

type Mode

type Mode string

Mode selects a CassetteProvider's record/replay behaviour. See NewCassetteProvider. It is a string type, not an int enum, so it marshals to human-readable JSON and prints readably in diagnostics (see AGENTS.md's "JSON artefacts carry human-readable string constants" convention) rather than a bare, meaningless integer.

const (
	// ModeLive calls the wrapped Provider directly and performs no cassette
	// I/O at all — exploratory only, never used in CI.
	ModeLive Mode = "live"
	// ModeRecord calls the wrapped (live) Provider and appends every
	// Propose call's prompt/outcome to the cassette; call Cassette then
	// Cassette.Save to persist it afterwards.
	ModeRecord Mode = "record"
	// ModeReplay never calls the wrapped Provider: every Propose call is
	// served from the cassette, and a cache miss is an error — the CI
	// default, at zero token cost.
	ModeReplay Mode = "replay"
)

Cassette modes. See Mode.

func (Mode) String

func (m Mode) String() string

String renders m for diagnostics and error messages.

type Prompt

type Prompt struct {
	GoalID          string
	GoalTitle       string
	GoalDescription string
	Constraints     []string

	TaskID              string
	TaskTitle           string
	TaskSuccessCriteria string

	// Observation is the current semantic snapshot: visible messages,
	// available actions and explicit changes. A Provider proposing
	// ProposeClick must copy ActionID from an action listed here, and
	// ObservationSequence from Observation.Sequence.
	Observation observe.Observation

	// History is the loop's last N LoopEvents preceding this prompt, oldest
	// first; N is the loop's configured history window (Config.HistoryWindow).
	// It includes invalid/no-effect attempts, so a Provider can see (and
	// avoid repeating) what did not work.
	History []LoopEvent
}

Prompt is everything a Provider needs to propose the next action: the goal/active-task context, the current semantic Observation — never raw platform payloads, see observe's own doctrine — and bounded recent history.

type Proposal

type Proposal struct {
	Kind ProposalKind `json:"kind"`

	// Text is set for ProposeSendText: the text to send as the user.
	Text string `json:"text"`

	// ActionID is set for ProposeClick: an observe.AvailableAction.ID drawn
	// from the Prompt's Observation.
	ActionID string `json:"actionId"`
	// ObservationSequence is the Observation.Sequence the proposal was
	// chosen from. Required for ProposeClick (fed to observe.Engine.Validate
	// as observe.ActionProposal.ObservationSequence); ignored otherwise.
	ObservationSequence int64 `json:"observationSequence"`

	// Rationale is free text explaining the choice — never private
	// chain-of-thought, just enough for a developer or the campaign report
	// to understand why the actor did this.
	Rationale string `json:"rationale"`
}

Proposal is a Provider's typed intent for the next action, plus its free-text rationale. The loop validates and executes it — see Loop.

type ProposalKind

type ProposalKind string

ProposalKind is the typed shape of a Provider's proposed action. It is a string type, not an int enum, so it marshals to human-readable JSON — in cassette files and everywhere else — rather than a bare, meaningless integer (see AGENTS.md's "JSON artefacts carry human-readable string constants" convention).

const (
	// ProposeSendText: send free text as the user.
	ProposeSendText ProposalKind = "send-text"
	// ProposeClick: activate a previously observed AvailableAction by its
	// opaque ID (Proposal.ActionID), as seen at Proposal.ObservationSequence.
	ProposeClick ProposalKind = "click"
	// ProposeTaskDone: the active task's success criteria are met.
	ProposeTaskDone ProposalKind = "task-done"
	// ProposeGiveUp: the active task cannot be completed; stop attempting it.
	ProposeGiveUp ProposalKind = "give-up"
)

Proposal kinds. See Proposal.

func (ProposalKind) String

func (k ProposalKind) String() string

String renders k for diagnostics, test failure messages and cassette files.

type Provider

type Provider interface {
	Propose(ctx context.Context, prompt Prompt) (Proposal, Usage, error)
}

Provider proposes the next action for an in-flight campaign task. It is a dumb transport: read a Prompt, return a Proposal and the Usage it cost. Nothing a Provider returns is trusted blindly — see Loop for the validate-then-act guard every Proposal passes through.

type ProviderFunc

type ProviderFunc func(ctx context.Context, prompt Prompt) (Proposal, Usage, error)

ProviderFunc adapts a plain function to the Provider interface, the same way http.HandlerFunc adapts a function to http.Handler.

func (ProviderFunc) Propose

func (f ProviderFunc) Propose(ctx context.Context, prompt Prompt) (Proposal, Usage, error)

Propose calls f.

type ScriptedProvider

type ScriptedProvider struct {
	// contains filtered or unexported fields
}

ScriptedProvider is a deterministic Provider driven by a fixed, ordered script of Proposals — no model, no network, no cassette needed. It exists for tests and the CI replay gate: a campaign run against a ScriptedProvider is exactly reproducible on its own, at zero cost, every time.

ScriptedProvider ignores the Prompt it is given — it is a fixed sequence, not a policy — so a caller that needs to react to what is actually observed (e.g. an opaque observe.AvailableAction.ID only known once the conversation is under way) should use ProviderFunc instead.

func NewScriptedProvider

func NewScriptedProvider(usage Usage, script ...Proposal) *ScriptedProvider

NewScriptedProvider returns a ScriptedProvider that proposes each of script's entries in order, one per Propose call, always reporting usage verbatim.

func (*ScriptedProvider) Propose

func (p *ScriptedProvider) Propose(_ context.Context, prompt Prompt) (Proposal, Usage, error)

Propose returns the next scripted Proposal, or ErrScriptExhausted once the script runs out.

type TaskResult

type TaskResult struct {
	TaskID string
	// Status is the task's goal.TaskStatus when RunTask returned.
	Status goal.TaskStatus
	// Stopped is true if the whole campaign had stopped (any
	// goal.StopReason, including this task completing the goal) by the time
	// RunTask returned.
	Stopped bool
	// NonProgress is true if this task's run ended specifically via the
	// loop's own non-progress detection (Config.NonProgressLimit) rather
	// than a goal.CampaignState-native stop. When true, Stopped is also
	// true: the loop stops the whole campaign (via Abort) rather than
	// silently moving on to another task, since non-progress on one task is
	// evidence the actor itself is stuck, not that the task is unreachable.
	NonProgress bool
}

TaskResult is what one Loop.RunTask call produced.

type Usage

type Usage struct {
	Model        string        `json:"model"`
	InputTokens  int           `json:"inputTokens"`
	OutputTokens int           `json:"outputTokens"`
	Latency      time.Duration `json:"latencyNanoseconds"`
	Cost         *float64      `json:"cost,omitempty"`
}

Usage reports what one Propose call cost: model identity, token counts, latency and, optionally, a caller-priced Cost. When Cost is set, the loop feeds it to goal.CampaignState.RecordCost so a configured goal.Budgets.MaxCost is enforced.

type ValidationOutcome

type ValidationOutcome struct {
	// Checked is false for proposal kinds observe.Validate does not apply
	// to (ProposeSendText, ProposeTaskDone, ProposeGiveUp); Verdict and
	// Reason are meaningless when Checked is false.
	Checked bool            `json:"checked"`
	Verdict observe.Verdict `json:"verdict"`
	Reason  string          `json:"reason"`
}

ValidationOutcome is the loop's validate-step verdict for one proposal, carrying observe.Engine.Validate's own result verbatim when it applies.

Directories

Path Synopsis
Package anthropic is the first real actor/actor.Provider implementation: it calls the Anthropic Messages API to propose the next action for an in-flight campaign task.
Package anthropic is the first real actor/actor.Provider implementation: it calls the Anthropic Messages API to propose the next action for an in-flight campaign task.

Jump to

Keyboard shortcuts

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