Agent Instances and Subjects

Copy Markdown View Source

Spectre provides a long-lived owner for the ordered state of one logical Agent and one canonical Subject:

Spectre.AgentRef + Spectre.Subject -> one local Spectre.Instance

An Instance is not addressed by a PID, chat id, sender name, phone number, or conversation id. Those values can change across restarts and channels. The logical pair is portable; Spectre.Instance.Registry maps it to the current local process. Since 0.2.3, the AgentRef key contains only the logical Agent id. Its compiled module, declared version, and Stack digest are resolver hints and do not split the Instance when behavior changes.

Start and find an Instance

Add the dynamic supervisor owned by the host application:

children = [
  {Spectre.Supervisor, name: MyApp.SpectreSupervisor}
]

Then start or find the unique Instance:

subject = Spectre.Subject.new({:account, account.id})

{:ok, instance} =
  Spectre.instance(
    MyApp.SpectreSupervisor,
    MyApp.SupportAgent,
    subject
  )

{:ok, ^instance} =
  Spectre.lookup_instance(MyApp.SupportAgent, subject)

Concurrent instance/4 calls for the same pair converge on one PID. A different Subject gets a different Instance and independent State. Supplying an Spectre.AgentRef with an explicit logical id allows two intentionally separate Agent identities to use the same compiled module.

Spectre.summon/1,3 also selects this runtime when :subject is present:

{:ok, instance} =
  Spectre.summon(
    MyApp.SpectreSupervisor,
    MyApp.SupportAgent,
    subject: subject
  )

Calls without :subject retain the legacy conversation-scoped Spectre.Session behaviour.

Subject state and channel conversations

The persisted %Spectre.State{conversation_id: ...} of an Instance is its opaque, stable AgentRef + Subject scope. It does not change when the same Subject reaches that logical Agent through another authenticated channel, and two logical AgentRefs backed by the same compiled module remain isolated. Per-message channel origins remain on %Spectre.Input.Source{conversation_id: ...} and are propagated to runtime integrations as :origin_conversation_id.

Spectre.Instance.info/1 exposes only hashed conversation keys with channel, count, and last-Run metadata. It never exposes the raw chat, thread, or session identifier. This lets one Subject share ordered state across authorized channels without treating a conversation id as identity proof.

Definition activation and ownership fencing

An Instance can bind its stable identity to one current immutable Definition:

{:ok, activation} =
  Spectre.activate(instance, candidate_ref,
    expected_generation: 0,
    authority_epoch: 8
  )

^activation = Spectre.activation(instance)

The Candidate must already be published in the Instance's configured :definition_store. Activation re-reads the Candidate and all publication artifacts, checks generation CAS and the current owner fence, then commits the canonical activation section. A durable checkpoint write is synchronous at this boundary; conflict or ambiguity never falls back to the old or new value by guesswork.

Every Instance claims a Spectre.Instance.Owner.Lease. The default local adapter is suitable only when one VM/local Registry is the canonical owner. Multi-node hosts must configure :owner with a linearizable lease and monotonic fencing token. Lease loss blocks admission, commits, activation, and Effect/operation dispatch.

See Stable Identity, Activation, and Definition-Pinned Runs for the publication flow and Migrating to 0.2.3 for legacy key migration.

Private Skill state

The format-tagged schema-3 checkpoint also retains private Skill state as Definition-owned, generational branches. The active branch is selected by the current Activation; older branches remain dormant until an explicit resume, fork, migration, abandonment, or reference-safe retention transition. Activating A → B → A never merges B's state into A.

State reads and writes remain serialized by the same Instance owner. Writes require exact schema, generation, and revision checks plus current Definition authority and owner fencing. See Generational Skill State and Migrating to 0.2.5.

Run ownership and observable boundaries

Each input creates a Run retained by the Instance. The ready queue is FIFO and deduplicated. One Move is selected through an Instance mailbox message, so calls, monitoring, registry failure, and correlated worker results remain ordinary OTP messages rather than recursive reducer calls.

Run creation and its first advance form one bounded two-Move sequence. Input normalization runs in the Move worker against the latest committed State, not inside the GenServer callback. Later closed {:continue, run} steps return to the FIFO tail. A worker crash fails only its owned Run, and a duplicate caller-provided Run id is rejected before normalization or provider work.

{:ok, turn} = Spectre.turn(instance, "prepare the report")

case turn.observable do
  {:reply, output, ref} ->
    deliver_once(output, Spectre.Run.Ref.token(ref))

  {:awaiting, ref} ->
    Spectre.resume(instance, ref, {:execute, ref})

  {:needs, _request} ->
    Spectre.Turn.resolve_policy(turn, {:accept, :approved})
end

The public Turn contains only the boundary projection. The Instance keeps the Run and verifies the supplied revision-fenced ref before resuming it. Spectre.Instance.info/1 and run/2 do not return provider payloads or the internal continuation, but they are trusted local diagnostics rather than a safe object to log wholesale: caller-chosen IDs and explicitly published Operation.View values can still be present. Apply host redaction before exporting them. Both info and checkpoint-status reads are passive and do not reset the Instance idle timer; trace_id/1, which derives from info, inherits that behavior. This is a narrow monitoring exception: direct state, ref/1, agent/1, configuration, lifecycle, retained-record, and checkpoint-payload reads count as active use and re-arm the timer.

Effect and inference work run outside the Instance mailbox. Their internal receipts are fenced by Instance generation, Run id and revision, Invocation id, and dispatch id. Inference also carries attempt, control-revision and stream epoch fences. Late, duplicate, foreign, and stale receipts are ignored. The Instance remains responsive while a capability is in flight.

Each Effect and policy Awaitable staged by an Instance carries its owning Run id. Several Runs can therefore wait independently at policy or Effect boundaries while sharing the Subject's ordered State. Ordinary input is matched to an open policy Run by its channel conversation origin. If no origin is available, the legacy shortcut is safe only when exactly one policy is open; several possible owners return {:ambiguous_instance_policy, run_ids}. An Effect boundary is always resumed explicitly with its Invocation ref.

The Instance still applies one state-changing Move at a time. Before a retained Run advances, it is rebased onto the latest committed shared State, including lifecycle entries owned by other Runs. Capability Invocations use a separate state lock: calls arriving while one is in flight stay queued and continue after its terminal state has been committed.

Stateless Runtime calls and conversation-scoped Sessions do not opt into per-Run lifecycle ownership and retain the single pending Effect contract.

Operational loops on the same Instance

In 0.2.0 the same Instance also owns the canonical state of Work, Vigil, and authorized external controllers. Conversational Runs and operational loops remain separate domains, but all committed changes are serialized by this one Subject-scoped owner.

{:ok, work_ref, _view} =
  Spectre.start_work(instance, MyApp.ExportReport, %{report_id: report_id})

{:ok, vigil_ref, _view} =
  Spectre.register_vigil(instance, MyApp.AccountVigil, %{account_id: account_id})

{:ok, work} = Spectre.loop(instance, work_ref)
{:ok, vigil} = Spectre.loop(instance, vigil_ref)

The Instance has a bounded operational Runner pool, configured with :max_operation_runners. Each Runner receives a revision-fenced snapshot, executes one registered operation attempt outside the Instance mailbox, sends one correlated Result, and terminates. A waiting or paused loop retains no Runner. Retry, recovery, control, and canonical commit decisions remain with the Instance.

Use loops/2 for visible projections and resolve_loop/3 when a natural command may identify a loop by kind, Definition, status, or origin. Multiple matches return an explicit ambiguity error. Pause, update, resume, renew, trigger, and stop are durable commands; stop is terminal while pause is reversible.

The complete canonical graph can be persisted through Spectre.Instance.CheckpointStore. Configure :checkpoint_store on Instance startup and use flush_checkpoint/2, checkpoint_status/1, and reconcile_checkpoint/2 at the host boundary. An ambiguous compare-and-swap write erects a persistence fence and is never retried automatically.

Current format-tagged schema-3 checkpoints retain the Activation, every retained Run, Definition lifecycle and event records, the complete private Skill-state branch graph, inference control/progress, and the required-receipt outbox. The reader accepts tagged versions 2 and 3; retired untagged 0.2.x schemas are rejected instead of being guessed or silently migrated. migrate_instance_key/5 migrates a validated checkpoint between the old and stable Instance keys; it is not a legacy schema decoder. Divergent histories under old and new keys are rejected. See Migrating canonical Instance checkpoints to v3.

See Work, Vigil, and the operational runtime for controller, operation, recovery, event, and delivery contracts.

Inference Invocations and streaming

Model selection is now an explicit Spectre.Invocation kind. The Instance commits selection and dispatch intent before it releases one-shot or streaming provider work, then accepts one typed terminal worker receipt through the same owner fences as Effects. This removes provider latency from the Instance mailbox without creating a second owner for the Run.

Spectre.stream/3 starts an Instance-owned, pull-driven Enumerable. Raw deltas flow directly from the supervised stream session to one authoritative consumer; they do not pass through the Instance or enter its checkpoint. The Instance receives text-free heartbeat snapshots for watchdogs and, when enabled, commits throttled progress before publishing it to observers.

Cancellation is committed before remote cancel is requested. Steering is a restart: the old Enumerable ends as superseded and the caller receives a new handle with a new epoch. A consumer that never attaches becomes the explicit terminal :consumer_never_attached; Instance death interrupts the session and best-effort cancels the provider. Recovery either resumes/reconciles through a declared adapter capability or reaches an explicit interrupted/ambiguous terminal state. See Streaming inference.

Conversational Move scheduling

The GenServer mailbox never executes input plugs, routing, model calls, memory callbacks, renderers, or Actions directly. An ordinary Move runs bounded pure or host callbacks in its worker, so operational calls such as info/1 remain responsive. When that Move reaches an inference boundary it returns a portable continuation; the Instance commits it and dispatches the provider separately. Ready Runs therefore do not hold the Move lock for model latency.

The explicit in-flight Invocation + Receipt path covers staged Effect/Action execution, including Lens when mounted as an Action, and selected inference. Post-processing resumes only after a correlated provider receipt and is rebased against current shared State before its canonical commit.

This conversational one-Move limit is independent from the bounded operational Runner pool described above.

External identities and explicit linking

Channel adapters authenticate a provider principal first, then construct an opaque Spectre.ExternalIdentity:

identity =
  Spectre.ExternalIdentity.new(
    provider: :beam,
    channel: :telegram,
    endpoint: :support_bot,
    principal_id: verified_sender_id,
    authenticated_at: System.system_time(:millisecond),
    proof_ref: authentication_receipt_id
  )

The raw principal is hashed at construction. Authentication is a channel responsibility; the core does not infer it from populated source fields.

An already verified bootstrap can bind an identity with explicit proof:

{:ok, link} =
  Spectre.Subject.Registry.bind(
    MyApp.SupportAgent,
    subject,
    identity,
    proof: authentication_receipt
  )

Afterward, ingress resolves the exact Agent-scoped identity before looking up the Instance:

{:ok, subject, _link} =
  Spectre.Subject.Registry.resolve(MyApp.SupportAgent, identity)

{:ok, instance} =
  Spectre.instance(MyApp.SpectreSupervisor, MyApp.SupportAgent, subject)

Adding another channel uses a one-time destination challenge:

{:ok, intent, challenge} =
  Spectre.Subject.Registry.open_link(
    MyApp.SupportAgent,
    subject,
    existing_identity,
    destination_identity,
    ttl: :timer.minutes(5),
    attempts: 3
  )

# Deliver `challenge` only through the authenticated destination channel.
{:ok, destination_link} =
  Spectre.Subject.Registry.confirm_link(
    intent.id,
    destination_identity,
    received_challenge
  )

Only the challenge digest is retained. Challenges expire, have bounded attempts, and are one-time. A policy can require source_confirmation?: true, in which case confirm_source/3 must also be called from the already linked identity. Binding an identity already owned by another Subject returns a conflict; no name, address-book, number-similarity, message-text, or model-based merge exists.

revoke/2 removes future resolution through a link without deleting the Subject or Instance State. Successful commits and revocations pass through the privacy-safe Journal boundary before becoming visible.

The bundled Subject Registry and active-Run registry are local in-memory coordination for this phase. Applications that require cross-node routing or durable identity recovery should persist the exposed value objects and re-establish them through an authenticated host workflow; they must not infer links after restart.