thread

package module
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package thread gives weft sessions: a conversation as an append-only tree of entries, durable through a Storage backend, with branching, compaction (ADR 0020) and approvals (ADR 0021) all built on the same tree. ADR 0011 is the design record; this package owns the format and the Storage contract, and the backends live beside it (jsonl on disk, Memory in process, sqlite later).

A session is a file you can read with jq and back up with cp: one header line ({"weft":1,"type":"session",…} — the envelope integer every weft wire document carries, ADR 0005), then one line per entry in append order. Nothing is ever rewritten or deleted in place; the leaf is the entry the next one attaches to, and the model's context is built by walking leaf → root (Session.Context, v0.1).

The entry kinds are sealed, like weft.Event: message, turn, compaction, branch_summary, leaf, label, info, custom, custom_message — one wire discriminator each, restored by UnmarshalEntry; a message entry embeds the core's message wire (ADR 0001) verbatim. The format grows without breaking readers: an entry kind added after format 1 carries "v":N, the minimum reader version, and a reader that cannot decode an entry fails with ErrNewerFormat instead of skipping it. Golden files in testdata pin every format version, and every release reads them all back.

Storage is the backend interface — Create, Append, Load, List, Delete — and threadtest is the conformance table every backend runs before it may call itself one. Entries and headers never alias storage internals: what Load returns is the caller's to keep.

v0.1 completes the layer above this format (Session, Send/Turn, branching, compaction); the current surface is the format, the Storage contract, and the Session tree they stand on — Create/Open, the context walk, entries, leaf and path, labels, info, custom state, branching. Send and compaction follow in the next steps of v0.1.

Index

Examples

Constants

View Source
const (
	StepGrant    = "grant"    // step 2.2: a matching live grant
	StepApprover = "approver" // the live chain step, consulted and bounded
	StepPark     = "park"     // the request persisted, the turn ended pending
	StepExpiry   = "expiry"   // an expired request denied on resume
	StepResume   = "resume"   // the parked boundary resumed under its decisions
)

The audit entry's steps (ADR 0021 §2): every step the decision chain takes leaves an approval_audit entry naming its step. Grants join in step 2.2.

View Source
const (
	// ReceiptQueued marks acceptance: the message is held for the
	// running turn's steering drain.
	ReceiptQueued = "queued"
	// ReceiptDelivered marks a message the running run drained: it is
	// an ordinary transcript message of that run, named by RunID.
	ReceiptDelivered = "delivered"
	// ReceiptDeferred marks a message that became a follow-up turn
	// (Turn names its receipt): a StopWhen end, an open approval
	// boundary, or the run ended before the drain.
	ReceiptDeferred = "deferred"
	// ReceiptDropped marks a message removed by ClearQueue before
	// delivery: it never reaches the model.
	ReceiptDropped = "dropped"
)

Receipt statuses — the wire values, pinned by the format-3 goldens.

View Source
const (
	// PoolAccepted marks the delegation recorded and queued for a slot.
	PoolAccepted = "accepted"
	// PoolRunning marks the slot acquired and the child session's turn
	// started — the wait between acceptance and running is the pool's
	// queue, visible.
	PoolRunning = "running"
	// PoolDone marks a child that ran to its intended end; Stop is its
	// answer, Usage its total cost.
	PoolDone = "done"
	// PoolFailed marks a child whose run failed; Stop is the cause.
	PoolFailed = "failed"
	// PoolCanceled marks a child canceled by an explicit Cancel (or the
	// pool's Close) — never by the submitting turn's own end, which an
	// async child survives by design (ADR 0022 D4).
	PoolCanceled = "canceled"
	// PoolCapped marks a child that died on a budget — ErrMaxSteps or
	// ErrUsageLimit (DeerFlow's token-capped/turn-capped collapsed; the
	// stop reason distinguishes them).
	PoolCapped = "capped"
)

Pool receipt statuses — the wire values, pinned by the format-4 goldens. The machine is accepted → running → exactly one of done, failed, canceled, capped.

View Source
const FormatVersion = 1

FormatVersion is the wire version of every document the thread module writes (ADR 0011 §6) — the integer every weft wire document carries ("weft": 1, ADR 0005). It moves only for a layout change a reader cannot handle additively: a new entry kind, a new optional key, a new entry version never move it. Readers read every version forever; writers write the latest; a layout change ships its reader plus an explicit, atomic Migrate — never a silent rewrite on open.

Variables

View Source
var (
	// ErrNotImplemented named the pieces of the v0.1 design whose step
	// had not landed yet — SummarizeLeft until step 1.8 wrote branch
	// summaries (ADR 0020 §6). Every such step has since shipped and
	// nothing returns it today; it stays exported so code written
	// against the v0.1 shape — callers that matched it to degrade
	// gracefully — still compiles and matches.
	ErrNotImplemented = errors.New("thread: feature not implemented in this build")

	// ErrBusy is returned by Send under the Reject busy policy when the
	// session is already running a turn: the follow-up was not
	// accepted and nothing was written (ADR 0011 §4).
	ErrBusy = errors.New("thread: session is busy with another turn")

	// ErrNotFound is returned by Load, Append, and Delete for a session
	// id the storage does not hold.
	ErrNotFound = errors.New("thread: session not found")

	// ErrExists is returned by Create for a session id the storage
	// already holds — in this process, or in any other sharing the
	// backend. A session is never silently replaced (ADR 0011 §5).
	ErrExists = errors.New("thread: session already exists")

	// ErrLocked is returned by a backend that enforces the one-writer
	// rule (ADR 0011 §5) when a session is already held by another
	// writer — another process, or another Storage in this one.
	// Readers never lock: Load and List always work.
	ErrLocked = errors.New("thread: session is locked by another writer")

	// ErrCorrupt wraps the failures a backend reports for stored data
	// it cannot decode: a malformed line that is not a torn tail (a
	// torn final line is a crash, dropped and reported through Load's
	// LoadReport; data from a newer weft is ErrNewerFormat, never
	// skipped). Salvage, the jsonl open option, downgrades this to a
	// skip reported in the LoadReport.
	ErrCorrupt = errors.New("thread: stored session data is corrupt")

	// ErrNewerFormat wraps the decode failures UnmarshalEntry and
	// Header decoding report for data written by a newer weft than
	// this build: an entry kind this build does not know, an entry
	// carrying a "v" above the version this build reads of its kind,
	// or a session header whose envelope is ahead of FormatVersion
	// (ADR 0011 §5–§6). Loud over silent: a session must decode to
	// exactly what was written, and an older weft says so instead of
	// guessing. List, which reads headers only, still works — an older
	// reader sees a session whose entries are newer and Load names why
	// it cannot open it. The one thing List cannot show is a session
	// whose header itself is newer: the envelope gates the whole file,
	// so such a session is invisible to an older List — visible again
	// as soon as its directory is read by a weft that knows the format.
	ErrNewerFormat = errors.New("thread: session format newer than this build")

	// ErrNotPending is returned by Decide for a decision addressing a
	// call that is not pending — decided already, resumed already, or
	// never parked — and by Resume with no open boundary. The core's
	// rule (ADR 0007: a decision names a pending call) made strict and
	// raised before any entry lands or any run starts: a session never
	// records a decision it cannot apply.
	ErrNotPending = errors.New("thread: call is not pending")

	// The signed-decision failures (ADR 0021 §3), each its own error
	// because each is its own operational answer: the key the ring
	// does not hold, the signature that does not verify, the challenge
	// that lapsed, the nonce that was answered already, and the
	// arguments that changed under the request. All are fail-closed:
	// nothing is recorded until every check passes.
	ErrUnknownKey   = errors.New("thread: signing key not in the keyring")
	ErrBadSignature = errors.New("thread: decision signature does not verify")
	ErrExpired      = errors.New("thread: signing challenge expired")
	ErrReplay       = errors.New("thread: decision signature replayed")
	ErrArgsChanged  = errors.New("thread: request arguments changed under the signature")

	// ErrSignatureRequired is returned by Decide on a session opened
	// with RequireSigned: the unsigned door is closed, and only
	// DecideSigned records caller-held decisions (ADR 0021 §3).
	ErrSignatureRequired = errors.New("thread: this session requires signed decisions")
)
View Source
var Cancel = Verdict{/* contains filtered or unexported fields */}

Cancel stops it: nothing is written.

View Source
var Proceed = Verdict{}

Proceed runs the compaction as computed.

Functions

func Delete

func Delete(ctx context.Context, st Storage, id string) error

Delete removes a session and its entries from st. A Session value already loaded keeps its in-memory tree, but its next append fails with ErrNotFound: history is removed with the session, never rewritten (ADR 0011 §5).

func NewEntryID

func NewEntryID() string

NewEntryID returns a new entry id: "e_" plus 26 time-sortable random characters (see NewSessionID).

func NewSessionID

func NewSessionID() string

NewSessionID returns a new session id: "s_" plus 26 characters — a millisecond-precision timestamp then random bits, Crockford base32, the ULID shape — so ids sort with the sessions they name. Sorting is to the millisecond only: ids born in the same millisecond order randomly among themselves, and a session's order of record is its entry order, never id order.

Example

NewSessionID and NewEntryID mint time-sortable ids: "s_" and "e_" plus 26 characters, so a directory of sessions lists in creation order and a session's entries debug readably.

package main

import (
	"fmt"

	"github.com/weftgo/weft/thread"
)

func main() {
	fmt.Println(len(thread.NewSessionID()), len(thread.NewEntryID()))
}
Output:
28 28

func ValidID

func ValidID(id string) bool

ValidID reports whether id is safe as a session or entry id: 1 to 128 characters of letters, digits, '_' or '-', so an id is always exactly one path component — never a separator, a traversal, or a dotfile (ADR 0011 §5: backends vet the ids they are given). weft's own ids satisfy it by construction; the ids a caller supplies must stay inside it too.

Types

type ApprovalAuditEntry

type ApprovalAuditEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	CallID   string    `json:"call_id,omitempty"`
	Step     string    `json:"step"`
	Outcome  string    `json:"outcome,omitempty"`
	Detail   string    `json:"detail,omitempty"`
	RunID    string    `json:"run_id,omitempty"`
}

ApprovalAuditEntry is the chain's own trail (ADR 0021 §2): every step the decision chain takes over a call — the Approver consulted (decided, declined, timed out), the park, an expiry denial, a resume — leaves one of these, including automatic approvals, so s.Audit() can tell the whole story from the file alone. Step and Outcome name the step and how it ended; Detail carries anything beyond that. It never enters the model's context.

func (ApprovalAuditEntry) MarshalJSON

func (e ApprovalAuditEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":2.

type ApprovalDecisionEntry

type ApprovalDecisionEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	CallID   string    `json:"call_id"`
	Outcome  Outcome   `json:"outcome"`
	Reason   string    `json:"reason,omitempty"`
	Content  string    `json:"content,omitempty"`
	Who      string    `json:"who,omitempty"`
	Via      string    `json:"via,omitempty"`
	RunID    string    `json:"run_id,omitempty"`
	// Nonce and KeyID are the signed-decision replay guard's record
	// (ADR 0021 §3): a decision that arrived signed carries the nonce
	// it answered and the key that vouched for it, so a replayed
	// signature is detectable from the file alone — across restarts.
	// Empty on the in-process paths, which mint no challenge.
	Nonce string `json:"nonce,omitempty"`
	KeyID string `json:"key_id,omitempty"`
}

ApprovalDecisionEntry is one decision over a parked call (ADR 0021 §1): approve, deny with a reason, or resolve with content computed outside the process (resolve_error marks it an error). It records Who decided, When (the entry's Created), Via which channel — "user" for a plain Decide, "approver" for the live chain step, "expiry" for an expired request's automatic denial — and the run the decided request belonged to. It never enters the model's context; the model sees the decision only through the result the resumed run produces.

func (ApprovalDecisionEntry) MarshalJSON

func (e ApprovalDecisionEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":2.

type ApprovalRequestEntry

type ApprovalRequestEntry struct {
	ID         string          `json:"id"`
	ParentID   string          `json:"parent,omitempty"`
	Created    time.Time       `json:"created"`
	CallID     string          `json:"call_id"`
	Tool       string          `json:"tool"`
	Args       json.RawMessage `json:"args,omitempty"`
	ArgsSHA256 string          `json:"args_sha256"`
	RunID      string          `json:"run_id"`
	Reason     string          `json:"reason,omitempty"`
	Expiry     time.Time       `json:"expiry,omitzero"`
	// Child names the delegated child session this request mirrors,
	// and Wrapper the parent-side delegating call it parks under (ADR
	// 0022 §7): a pool child's parked call is requested in its own
	// session and mirrored here so the parent's Pending surfaces it
	// with its lineage. Both empty on an ordinary request.
	Child   string `json:"child,omitempty"`
	Wrapper string `json:"wrapper,omitempty"`
}

ApprovalRequestEntry is a parked call made durable (ADR 0021 §1): a call the core's approval boundary left unexecuted, recorded with the arguments and their SHA-256 so a decision can name exactly what it decided, the run that parked it, why it parked, and an optional expiry — a request past its expiry is denied with the stated reason on the next resume (ADR 0021 §5). It is written in the same Append as the turn that parked it, so no window exists where the turn is durable and the request is not. It never enters the model's context; the pending call itself stays unresolved in the transcript until a decision resolves it.

func (ApprovalRequestEntry) MarshalJSON

func (e ApprovalRequestEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":2.

type Approver

type Approver func(ctx context.Context, r Request) (Decision, bool)

Approver is the decision chain's live step (ADR 0021 §2): the "ask now" path for a terminal or an already-connected UI. It returns the Decision and true when it decided, or false to decline (a pipe, no TTY). It is never given unbounded time: the session consults it under ApproverTimeout, and a timeout reads as a decline. It must not call back into the same Session — the chain consults it outside the session lock, but the turn is still running.

type Arg

type Arg struct {
	// Pointer is the RFC 6901 JSON pointer to the argument: "/order_id",
	// "/file/path", "/command".
	Pointer string `json:"pointer"`
	// Equals is the raw JSON the value must equal (ArgEquals).
	Equals json.RawMessage `json:"equals,omitempty"`
	// Prefix is the string prefix the value must have (ArgPrefix).
	Prefix string `json:"prefix,omitempty"`
	// Glob is the glob the string value must match (ArgGlob) — *
	// spans separators, a command is not a path.
	Glob string `json:"glob,omitempty"`
}

Arg is one argument predicate (ADR 0021 §4): a JSON pointer into the call's arguments and the test the value there must pass — equality with ArgEquals, a string prefix with ArgPrefix, a glob with ArgGlob. Build one with its constructor; a hand-made Arg with several tests set uses Equals first, then Prefix, then Glob.

func ArgEquals

func ArgEquals(pointer string, value json.RawMessage) Arg

ArgEquals returns the predicate requiring args[pointer] to equal value as JSON: numbers compare numerically, strings and booleans exactly, arrays and objects deeply.

func ArgGlob

func ArgGlob(pointer, glob string) Arg

ArgGlob returns the predicate requiring args[pointer] to be a string matching glob — the "go test …" command shape. A command is not a path: * spans separators, so "go test*" matches "go test ./..." (and anything after it on the line — anchor the glob's tail when that matters).

func ArgPrefix

func ArgPrefix(pointer, prefix string) Arg

ArgPrefix returns the predicate requiring args[pointer] to be a string with prefix — the "path stays under the workspace" shape. A prefix is a plain string prefix, nothing more: end it with the separator or it matches siblings too ("/ws" matches "/ws-evil") — the prefix's one sharp edge, documented rather than hidden. An empty prefix matches nothing (an Arg with no test never matches, the same rule a hand-made Arg follows): the tool-wide grant is no Args at all, not an empty prefix.

type BranchOption

type BranchOption interface {
	// contains filtered or unexported methods
}

BranchOption configures one Branch call — the per-call layer over the session's own SessionOptions. v0.1 step 1.6 carries one: SummarizeLeft, wired in step 1.8.

func SummarizeLeft

func SummarizeLeft() BranchOption

SummarizeLeft returns the BranchOption that summarizes the branch being left — back to the common ancestor of the branch and the new branch point — into a branch_summary entry the new branch's context carries in the abandoned branch's place (ADR 0020 §6). The summarizer runs under the session's compaction chain (SummaryModel or the session's own model, the skeleton prompt) with the lock released, then the navigation and the summary land as one atomic batch.

type BranchSummaryEntry

type BranchSummaryEntry struct {
	ID        string    `json:"id"`
	ParentID  string    `json:"parent,omitempty"`
	Created   time.Time `json:"created"`
	Summary   string    `json:"summary"`
	FromEntry string    `json:"from_entry"`
}

BranchSummaryEntry summarizes the branch a Session.Branch leaves behind (ADR 0020 §6): Summary is the text and FromEntry is the entry the abandoned branch grew from, so the context shows the summary in place of that branch's messages. Branch summaries share no cache prefix with the main line; that cost is documented, not hidden.

func (BranchSummaryEntry) MarshalJSON

func (e BranchSummaryEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type CompactOption

type CompactOption interface {
	// contains filtered or unexported methods
}

CompactOption configures one compaction call.

func Instructions

func Instructions(text string) CompactOption

Instructions returns the CompactOption appending per-call instructions to this compaction's summary prompt: s.Compact(ctx, thread.Instructions("focus on the API design")).

type Compaction

type Compaction struct {
	// Summary is the summarizer's text, shown in the context behind
	// the fixed marker from the entry's first kept entry onward.
	Summary string
	// FirstKept is the id of the first entry the context still shows
	// raw after this compaction.
	FirstKept string
	// TokensBefore is the estimated size of the whole context at
	// compaction time — the number the compaction was judged against.
	TokensBefore int64
	// Reason is why it ran: manual here, threshold for the trigger,
	// from_hook and trim in step 1.9, overflow in v0.3.
	Reason Reason
	// SummarizerUsage and SummarizerModel record what the summary
	// cost and which model made it — the cost ledger's inputs.
	SummarizerUsage weft.Usage
	SummarizerModel weft.ModelInfo
	// FilesRead lists the file URLs the summarized range carried,
	// sorted and deduplicated; FilesModified fills in when the sandbox
	// write log lands (v0.6, ADR 0023) — the lists are the summary's
	// memory across iterative compactions.
	FilesRead     []string
	FilesModified []string
	// Pinned lists the pinned entry ids this compaction kept raw; the
	// Pin call arrives in step 1.9 and the list is its record.
	Pinned []string
	// RangeHash is the SHA-256 of the serialized range the summarizer
	// was fed — the audit that a summary summarizes exactly this.
	RangeHash string
}

Compaction is one computed compaction: the plan PreviewCompaction returns, ApplyCompaction writes as a compaction entry, and Compact does both. It carries everything the entry records (ADR 0020 §1) — nothing else: applying writes exactly these fields, nothing is recomputed.

type CompactionEntry

type CompactionEntry struct {
	ID           string    `json:"id"`
	ParentID     string    `json:"parent,omitempty"`
	Created      time.Time `json:"created"`
	Summary      string    `json:"summary,omitempty"`
	FirstKept    string    `json:"first_kept"`
	TokensBefore int64     `json:"tokens_before"`
	Reason       Reason    `json:"reason,omitempty"`
	// SummarizerUsage and SummarizerModel name what the summary cost
	// and which model made it (the cost ledger, ADR 0020 §4) — absent
	// on a trim, which summarizes nothing.
	SummarizerUsage weft.Usage     `json:"summarizer_usage,omitzero"`
	SummarizerModel weft.ModelInfo `json:"summarizer_model,omitzero"`
	FilesRead       []string       `json:"files_read,omitempty"`
	FilesModified   []string       `json:"files_modified,omitempty"`
	Pinned          []string       `json:"pinned,omitempty"`
	RangeHash       string         `json:"range_hash,omitempty"`
}

CompactionEntry records one compaction (ADR 0020 §1): the summary text, the id of the first entry kept raw after it, the token count before, the reason it ran, the summarizer's cost and identity, the details (files read, files modified, pinned entries kept through), and a hash of the summarized range. Nothing is deleted — the summarized entries stay in the file, and the context at a leaf is the latest compaction's summary on the path plus the entries from its first kept id onward. A trim that needed no summary is the same entry with an empty Summary and Reason "trim".

func (CompactionEntry) MarshalJSON

func (e CompactionEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type Compactor

type Compactor interface {
	Compact(ctx context.Context, p Preparation) (*Compaction, error)
}

Compactor is the whole compaction algorithm, swapped in whole: it receives the Preparation and returns the Compaction to write. The default cuts, serializes, summarizes and hashes; a replacement may ignore the suggested cut and keep any FirstKept it can name.

type CorruptError

type CorruptError struct {
	Session string
	Line    int // 1-based; 0 when the failure is not one line's
	Err     error
}

CorruptError is the typed shape ErrCorrupt takes when the failure belongs to one line of a stored session (ADR 0011 §5: "ErrCorrupt naming the line"): it carries the session and the 1-based line number — the header is line 1 — so a caller, a log, or a UI can point at the place. Match the class with errors.Is(err, ErrCorrupt) and take the place with errors.As; backends construct it directly through their own decode-failure paths.

func (*CorruptError) Error

func (e *CorruptError) Error() string

func (*CorruptError) Unwrap

func (e *CorruptError) Unwrap() error

Unwrap makes errors.Is(err, ErrCorrupt) true — the class — while Err keeps the cause for a caller who wants it.

type CustomEntry

type CustomEntry struct {
	ID       string          `json:"id"`
	ParentID string          `json:"parent,omitempty"`
	Created  time.Time       `json:"created"`
	Kind     string          `json:"kind"`
	Data     json.RawMessage `json:"data,omitempty"`
}

CustomEntry carries application state: a caller-chosen Kind and opaque JSON Data. It survives every compaction (ADR 0020 §4) and never enters the model's context. A nil Data writes no "data" key — omitempty keeps nil and absent the same value both ways, so a round trip through the wire never turns "no data" into "null".

func (CustomEntry) MarshalJSON

func (e CustomEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type CustomMessageEntry

type CustomMessageEntry struct {
	ID       string       `json:"id"`
	ParentID string       `json:"parent,omitempty"`
	Created  time.Time    `json:"created"`
	Kind     string       `json:"kind"`
	Message  weft.Message `json:"message"`
}

CustomMessageEntry carries an application message: a caller-chosen Kind and a weft.Message that is always in the model's context — how an application injects a note the model must see without attributing it to the user.

func (CustomMessageEntry) MarshalJSON

func (e CustomMessageEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type Decision

type Decision struct {
	// CallID is the pending call the decision addresses.
	CallID string
	// Kind is the outcome: approve, deny, resolve or resolve_error.
	Kind Outcome
	// Reason is the deny reason — the text the model sees after
	// "DENIED: ".
	Reason string
	// Content is the resolve payload, verbatim; resolve_error marks it
	// an error.
	Content string
	// Who decided, for the audit trail — and the quorum's identity:
	// distinct Who values are distinct approvers (ADR 0021 §5).
	Who string
	// Via which channel the decision arrived.
	Via string
	// Always approves and grants the same thing for the future: the
	// tool plus the call's exact arguments become a session grant
	// ("approve and always allow this", ADR 0021 §4), recorded with
	// the decision in one append. Build it with ApproveAlways.
	Always bool
}

Decision is one call's outcome, the value Decide records (ADR 0021 §1). Build one with Approve, Deny, Resolve or ResolveError; Who and Via are the caller's to fill — Who names the deciding identity for the audit trail, Via the channel ("cli", "webhook", …); Via is forced to "approver" and "expiry" on the paths that own it.

func Approve

func Approve(callID string) Decision

Approve returns a Decision that resumes the call: the run executes it through the ordinary tool chain with Call.Approved set (ADR 0007).

func ApproveAlways

func ApproveAlways(callID string) Decision

ApproveAlways returns a Decision that approves the call and grants the same thing for the future (ADR 0021 §4's "approve and always allow this command"): the tool plus the call's exact arguments become a session grant, recorded with the decision in one append — so the next such call never parks. A richer grant (a command glob, a path prefix) is s.Grant's to make.

func Deny

func Deny(callID, reason string) Decision

Deny returns a Decision that resolves the call without running it: the model sees "DENIED: <reason>" and the loop continues.

func Resolve

func Resolve(callID, content string) Decision

Resolve returns a Decision that resumes the call with a result computed outside the process — the human-as-tool-executor shape. The handler never runs; the content becomes the call's result verbatim.

func ResolveError

func ResolveError(callID, content string) Decision

ResolveError is Resolve with the result marked as an error.

type Entry

type Entry interface {
	// contains filtered or unexported methods
}

Entry is the sealed set of session entry kinds (ADR 0011 §2): a session is an append-only tree of these, and the leaf is the entry the next one attaches to. External types cannot join, so switches over entries stay exhaustively lintable; weft adds kinds additively under the "v" rule below. On the wire every entry carries a "type" discriminator and UnmarshalEntry restores it — the same rule and the same compatibility contract as the core's events and message parts (ADR 0001, ADR 0004).

Every entry, of every kind, carries an ID, a ParentID (empty for the session's root entry) and a Created time. Whether a kind's content enters the model's context is part of the kind's contract: message and custom_message do; the bookkeeping kinds never do; compaction and branch_summary contribute their summary text.

func UnmarshalEntry

func UnmarshalEntry(b []byte) (Entry, error)

UnmarshalEntry decodes one entry line, dispatching on its "type" discriminator. The loud rules of the format (ADR 0011 §5–§6): an entry kind this build does not know fails with ErrNewerFormat — it was written by a newer weft and is never silently skipped; a known kind carrying a "v" above the version this build reads of it fails the same way; every other unknown key on the line is additive and ignored.

type Estimator

type Estimator interface {
	Estimate(msgs []weft.Message) int
}

Estimator estimates the token weight of messages — the trigger's delta and the cut's walk. The default is a quarter of the wire bytes; a provider-aware implementation can do better.

type Flusher

type Flusher interface {
	Flush(ctx context.Context, session string) error
}

Flusher is the optional Storage capability that completes buffered durability work — the small-interface rule (ADR 0011 §5: capabilities are discovered by type assertion, so Storage never grows). With FsyncOnFlush, an append writes without fsyncing and Flush makes the session durable; with the default policy Flush is a no-op that still returns any error the storage holds for the session. Flushing a session the storage does not hold fails with ErrNotFound; a session another writer holds but this one never buffered flushes nothing.

type Grant

type Grant struct {
	// Tool is the tool name the grant matches, exactly.
	Tool string `json:"tool"`
	// Args are the argument predicates; every one must match. Empty
	// matches any arguments, which is the tool-wide grant Crush
	// taught the field to want more than (ADR 0021 §4's rejected).
	Args []Arg `json:"args,omitempty"`
	// Deny makes the grant a standing refusal: the chain denies the
	// call at once, with Reason the model sees.
	Deny bool `json:"deny,omitempty"`
	// Reason is the refusal's text on a deny-grant — the model-visible
	// bytes, "denied by grant" when empty — or the record's note on an
	// approval grant.
	Reason string `json:"reason,omitempty"`
	// Expiry is when the grant lapses; zero means never. Expired
	// strictly after, the requests' rule.
	Expiry time.Time `json:"expiry,omitzero"`
	// MaxUses bounds how many times a session-scoped grant may match
	// (its audit entries count the uses); 0 means unlimited. A shared
	// grant's uses are the store's own business — the session cannot
	// see other sessions' matches.
	MaxUses int `json:"max_uses,omitempty"`
}

A Grant approves — or, with Deny, refuses — future calls without asking (ADR 0021 §4): one tool, and argument predicates every one of which the call's arguments must satisfy, so "allow run_command for go test …" is expressible, not just "allow run_command". Scope, expiry, uses and revocation are the grant's lifetime: a session-scoped grant is an entry (s.Grant), a shared grant lives behind the GrantStore interface; an expiry passes; MaxUses bounds the times a session-scoped grant may match (counted from its audit trail); s.Revoke ends one with an entry.

type GrantEntry

type GrantEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	Grant
}

GrantEntry is a session-scoped grant made durable (ADR 0021 §4): a standing approval — or, with Deny, a standing refusal — for future calls of one tool whose arguments match every predicate. It is the decision chain's first step: a matching live grant decides at once, audited, including the automatic approval. Liveness is derived, never stored: a GrantRevokedEntry naming the grant ends it, an Expiry passes, or its MaxUses is reached — uses counted from the audit entries the chain writes when it matches. It never enters the model's context; the model sees a grant only through the result of the call it allowed or refused.

func (GrantEntry) MarshalJSON

func (e GrantEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":2.

type GrantRevokedEntry

type GrantRevokedEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	GrantID  string    `json:"grant_id"`
}

GrantRevokedEntry ends a grant (ADR 0021 §4): revocation is an append, never a rewrite — the grant entry stays, the walk reads the revocation after it, and the audit trail keeps both.

func (GrantRevokedEntry) MarshalJSON

func (e GrantRevokedEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":2.

type GrantStore

type GrantStore interface {
	// Grants returns the store's live grants. The chain consults it
	// once per parked call, before parking; an error reads as no
	// match, with a warning through the agent's logger — a broken
	// store never parks nothing silently, and never decides either.
	Grants(ctx context.Context) ([]SharedGrant, error)
}

A GrantStore is the application-wide grant scope (ADR 0021 §4): grants that outlive any one session, consulted by every session handed the store. The store owns their lifetime — expiry, revocation, use counts — because only it can see every session's matches; the session's entry-scoped grants keep their own rules.

type Header struct {
	// Weft is the envelope integer. The zero value writes and means
	// FormatVersion; Create rejects anything else, so a caller never
	// has to know the number to construct a valid header.
	Weft    int               `json:"weft"`
	ID      string            `json:"id"`
	Created time.Time         `json:"created"`
	Parent  *ParentRef        `json:"parent,omitempty"`
	Lineage *Lineage          `json:"lineage,omitempty"`
	Meta    map[string]string `json:"meta,omitempty"`
}

Header is a session's first line: its identity, its creation time, its fork origin and caller metadata. A Fork names the session and entry it grew from in Parent (ADR 0011 §3), so the new session is self-contained but traceable; beyond that the header is immutable — title and metadata edits are info entries appended to the file, never rewrites of it.

Example

A session's header is its first line: the weft envelope integer, the session's id and creation time, and — for a fork — where it came from (ADR 0011 §3, §6).

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/weftgo/weft/thread"
)

func main() {
	h := thread.Header{
		ID:      "s_01J8X9M2K7QW4R5N8T6V2B3C4Q",
		Created: time.Date(2026, 9, 28, 12, 0, 0, 123456789, time.UTC),
		Parent:  &thread.ParentRef{Session: "s_01J8X9M2K7QW4R5N8T6V2B3C4D", Entry: "e_01J8X9M2K7QW4R5N8T6V2B3C4G"},
	}
	b, err := json.Marshal(h)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(b))
}
Output:
{"type":"session","weft":1,"id":"s_01J8X9M2K7QW4R5N8T6V2B3C4Q","created":"2026-09-28T12:00:00.123456789Z","parent":{"session":"s_01J8X9M2K7QW4R5N8T6V2B3C4D","entry":"e_01J8X9M2K7QW4R5N8T6V2B3C4G"}}

func (Header) MarshalJSON

func (h Header) MarshalJSON() ([]byte, error)

MarshalJSON encodes the header with its "type":"session" discriminator. A zero Weft writes FormatVersion — the zero Header is a valid, current-format header.

func (*Header) UnmarshalJSON

func (h *Header) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes a session header and enforces the envelope rule (ADR 0011 §6): a header from a newer format fails with ErrNewerFormat instead of being read with this build's tags, and a line without the session shape was never a thread session's first line. Unknown keys are additive and ignored.

type InfoEntry

type InfoEntry struct {
	ID       string            `json:"id"`
	ParentID string            `json:"parent,omitempty"`
	Created  time.Time         `json:"created"`
	Title    string            `json:"title,omitempty"`
	Meta     map[string]string `json:"meta,omitempty"`
}

InfoEntry edits the session's title and metadata as an append, never a rewrite: the current title is the last info entry's Title, and the current metadata is every info entry's Meta merged in order.

func (InfoEntry) MarshalJSON

func (e InfoEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type Key

type Key struct {
	ID     string
	Secret []byte
	Active bool
}

Key is one key in a Keyring: an id, the secret bytes (any non-empty length; HMAC-SHA256 derives its key from them), and whether it is the ring's one active signing key. At most one Key in a ring may be active; a ring with none signs nothing (verify-only).

type Keyring

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

A Keyring holds several HMAC keys by id; exactly one is active for signing and all of them verify (ADR 0021 §3): rotation without invalidating requests in flight — the new active key signs new challenges while the old keys vouch for decisions still arriving. Keys never touch the session file; only key ids do, so a leaked file cannot forge a decision. Construct one with NewKeyring and hand it to the sessions that verify signed decisions.

func NewKeyring

func NewKeyring(keys ...Key) (*Keyring, error)

NewKeyring builds a ring from keys: ids must be unique and secrets non-empty, and at most one key may be active. The ring is immutable from here — rotation is a new ring handed to new sessions, while the old ring keeps verifying the requests it minted.

type LabelEntry

type LabelEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	Entry    string    `json:"entry"`
	Name     string    `json:"name"`
}

LabelEntry names an entry — bookmarks, checkpoints, a UI's anchors. The label never enters the model's context.

func (LabelEntry) MarshalJSON

func (e LabelEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type LeafEntry

type LeafEntry struct {
	ID       string    `json:"id"`
	ParentID string    `json:"parent,omitempty"`
	Created  time.Time `json:"created"`
	Entry    string    `json:"entry"`
}

LeafEntry moves the session's leaf to an existing entry — branch navigation without a rewrite (ADR 0011 §3). The next entry attaches to Entry, and the model's context is rebuilt from there.

func (LeafEntry) MarshalJSON

func (e LeafEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type Lineage added in v0.5.0

type Lineage struct {
	Session string `json:"parent_session"`
	Call    string `json:"parent_call_id,omitempty"`
}

Lineage names a pool child's origin (ADR 0022 §3): the parent session and, for a wrapped delegation, the call that delegated. It is a reference, not a copy — unlike Parent, a fork's self-contained path, the child's file holds only its own entries, and the link is how a nested approval finds the session that must resume the child.

type LoadReport

type LoadReport struct {
	// Torn is the 1-based line number of a torn final line dropped
	// from the session file — a writer cut mid-write, so the bytes
	// after the last complete newline are not an entry — or 0 when
	// the file ended cleanly.
	Torn int
	// Skipped lists the 1-based line numbers of malformed lines the
	// load skipped under Salvage (the jsonl open option), in file
	// order. Without Salvage, a malformed line fails the load with
	// ErrCorrupt instead.
	Skipped []int
}

LoadReport names what a load had to drop or skip to return a session — a repair is never silent (ADR 0011 §5). A clean load returns a nil report.

type MessageEntry

type MessageEntry struct {
	ID       string       `json:"id"`
	ParentID string       `json:"parent,omitempty"`
	Created  time.Time    `json:"created"`
	Message  weft.Message `json:"message"`
}

MessageEntry is one weft.Message in the transcript, embedded with the core's message wire (ADR 0001) verbatim. It is how conversation content is stored, and it is always in the model's context.

Example

A session entry marshals with its "type" discriminator — one JSON line per entry is the on-disk format (ADR 0011 §2) — and UnmarshalEntry restores the sealed type.

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
)

func main() {
	e := thread.MessageEntry{
		ID:      "e_01J8X9M2K7QW4R5N8T6V2B3C4E",
		Created: time.Date(2026, 9, 28, 12, 0, 0, 123456789, time.UTC),
		Message: weft.User("Where is order 1234?"),
	}
	b, err := json.Marshal(e)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(b))
	restored, err := thread.UnmarshalEntry(b)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(restored.(thread.MessageEntry).Message.Role)
}
Output:
{"type":"message","id":"e_01J8X9M2K7QW4R5N8T6V2B3C4E","created":"2026-09-28T12:00:00.123456789Z","message":{"role":"user","content":[{"type":"text","text":"Where is order 1234?"}]}}
user

func (MessageEntry) MarshalJSON

func (e MessageEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type NativeCompactor

type NativeCompactor interface {
	CompactNative(ctx context.Context, req weft.ModelRequest, instructions string) (weft.Message, weft.Usage, error)
}

NativeCompactor is implemented by adapters whose provider compacts server-side. Root types only, so an adapter implements it without importing thread (ADR 0020 §7).

type OpenConfig

type OpenConfig struct {
	// Salvage downgrades a malformed line from a load failure
	// (ErrCorrupt) to a skip reported in the LoadReport. The unknown
	// and the newer stay loud: ErrNewerFormat is never salvaged.
	Salvage bool
	// SyncEveryAppend is the durability cadence: true (the default)
	// fsyncs every Append before it returns; false defers the fsync to
	// the Flusher capability — the turn-end cadence a Session drives.
	SyncEveryAppend bool
}

OpenConfig is the resolved configuration of an open: the defaults with every option applied. Backends resolve it through ResolveOpen; callers never construct it.

func ResolveOpen

func ResolveOpen(opts ...OpenOption) OpenConfig

ResolveOpen folds opts over the defaults. Exported because backends outside this package resolve the same option vocabulary into the same configuration.

type OpenOption

type OpenOption interface {
	// contains filtered or unexported methods
}

OpenOption configures a Storage backend at open, one value per concern, applied over the defaults. The options live here — in the package that owns the Storage contract — so every backend accepts the same vocabulary and a caller never learns a backend to say Salvage (ADR 0011 §5 names it thread.Salvage).

func FsyncEveryAppend

func FsyncEveryAppend() OpenOption

FsyncEveryAppend returns the open option that restores the default durability: every Append fsyncs before returning, so an accepted entry is durable before anything replies on it — the rule behind "the prompt is durable before the run starts" (ADR 0011 §4).

func FsyncOnFlush

func FsyncOnFlush() OpenOption

FsyncOnFlush returns the open option that defers the fsync to the Flusher capability — the turn-end cadence, cheaper than an fsync per append. A crash between appends and the flush can lose the tail of a turn, never a synced one; the file stays readable (a torn final line is dropped and reported). The caller who flushes at turn ends — Session, from v0.1 commit 7 — owns the durability window; a plain Flush after the prompt keeps ADR 0011 §4's promise.

func Salvage

func Salvage() OpenOption

Salvage returns the open option that skips malformed lines instead of failing the load: each skip is reported in Load's LoadReport (Skipped, in file order). A torn final line is always dropped and reported — that is a crash, not damage — and data from a newer weft still fails with ErrNewerFormat: salvage repairs what a crash wrote, never what it cannot read (ADR 0011 §5).

type Outcome

type Outcome string

Outcome is a decision's kind (ADR 0021 §1): approve (run the call through the ordinary chain), deny with a reason, or resolve with content computed outside the process, resolve_error marking it an error. The wire values are the decision entry's "outcome" field; they never change without a format version.

const (
	OutcomeApprove      Outcome = "approve"
	OutcomeDeny         Outcome = "deny"
	OutcomeResolve      Outcome = "resolve"
	OutcomeResolveError Outcome = "resolve_error"
)

type Page

type Page struct {
	// Sessions is the page, newest first by Created (ties by id,
	// descending), headers only — Load returns the entries.
	Sessions []Header
	// Total is the number of sessions matching the query, ignoring
	// Before and Limit — the "how many pages are there" number.
	Total int
}

Page is one List result.

func List

func List(ctx context.Context, st Storage, q Query) (Page, error)

List returns a page of session headers from st — headers only, never entries; see Query for the cursor and the limit. Listing needs no Session: it is the storage's List, nil storage aside.

type ParentRef

type ParentRef struct {
	Session string `json:"session"`
	Entry   string `json:"entry"`
}

ParentRef names a fork's origin: the session the new session was forked from, and the entry its copied path ends at (ADR 0011 §3).

type Policy

type Policy int

Policy is what a Send does when the session is already running a turn — the busy policy (ADR 0011 §4). Queue is the default.

const (
	// Queue holds the follow-up and runs it when the current turn
	// ends, in acceptance order: the send is accepted, its Turn is
	// returned at once, and its prompt becomes durable when its turn
	// starts. The default.
	Queue Policy = iota
	// Reject fails the send with ErrBusy: one run per session at a
	// time, and a busy session says so instead of holding work.
	Reject
	// Steer delivers the send into the running turn instead of
	// waiting for it (ADR 0019): the message is accepted at once —
	// its queued receipt entry durable, flushed — and handed to the
	// run's next steering drain point: after the step's tool batch,
	// every call paired with its result, or at what would have been
	// the final step, which the delivery redirects into one more
	// step. A steer that meets an intended end (StopWhen) or an open
	// approval boundary is never drained: it becomes a deferred
	// follow-up that runs as the next turn, the receipt recording
	// the fate. The Send's Turn is the receipt: it finishes — nil
	// result, nil error — when the steer reaches its final state,
	// and its Next is the follow-up turn a deferred steer became.
	Steer
	// Interrupt cancels the running turn and runs the message next
	// (plan §6): the in-flight run's context is canceled, the calls
	// its partial transcript left without a result carry the golden
	// interruption text, an approval boundary that holds the session
	// is denied with the interrupted reason, and the message runs as
	// the next turn. The interrupted turn's entries stay on the
	// tree — evidence, never deleted.
	Interrupt
	// Rollback is an Interrupt that also branches the leaf back to
	// before the interrupted turn's receipt entry: the follow-up runs
	// as though the interrupted turn never happened, while its
	// entries keep their own line of the tree (nothing lost).
	Rollback
)

type PoolReceiptEntry added in v0.5.0

type PoolReceiptEntry struct {
	ID       string     `json:"id"`
	ParentID string     `json:"parent,omitempty"`
	Created  time.Time  `json:"created"`
	Receipt  string     `json:"receipt,omitempty"`
	Status   string     `json:"status"`
	Child    string     `json:"child,omitempty"`
	Call     string     `json:"call,omitempty"`
	Prompt   string     `json:"prompt,omitempty"`
	Stop     string     `json:"stop,omitempty"`
	Usage    weft.Usage `json:"usage,omitzero"`
}

PoolReceiptEntry is the pool receipt (ADR 0022 §4): the journey of one child run a thread/pool started for this session. One entry records acceptance — Status "accepted", the child session on Child, the task on Prompt — a second records the slot acquisition and start ("running"), and a third, linked by Receipt, records the settlement: "done" (Stop carries the child's answer, Usage its total), "failed" (Stop the cause), "canceled" (an explicit Cancel), or "capped" (the child died on a budget — MaxSteps or a usage limit). Call names the delegating tool call for wrapped delegations. Pool receipt entries never enter the model's context: the answer reaches the model as the delegating call's result (a sync delegation) or however the application delivers it (an async one); the entry is the ledger, not the channel.

func (PoolReceiptEntry) MarshalJSON added in v0.5.0

func (e PoolReceiptEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":4.

type Preparation

type Preparation struct {
	Reason       Reason
	Context      []weft.Message
	Messages     []weft.Message
	SplitPrefix  []weft.Message
	PrevSummary  string
	FirstKept    string
	TokensBefore int64
	Instructions string
	Pinned       []string
}

Preparation is a computed compaction on its way to becoming one: everything the algorithm and the BeforeCompact hook see. Reason is why it runs; Context is the leaf's whole context; Messages the default cut's range to summarize; SplitPrefix the split turn's prefix when one turn alone overflows (nil in the one-call default, which folds it into Messages with the previous summary); PrevSummary the iterative chain's last link; FirstKept, TokensBefore and Pinned the entry's facts; Instructions the per-call text.

type Query

type Query struct {
	// Before is the paging cursor: only sessions created strictly
	// before it are returned; zero means start at the newest. Offsets
	// are deliberately absent — they drift under concurrent inserts
	// (the store's rule, ADR 0010 §0.1, inherited here). Sessions
	// sharing a Created time are ordered by id, so pages are
	// deterministic; a cursor that lands inside such a group needs the
	// last page's ids as well — callers paging through distinct times,
	// the normal shape, never see the seam.
	Before time.Time
	// Limit caps the page: 0 means 50, values above 500 clamp.
	Limit int
	// Meta filters by session metadata: every key must match its value
	// exactly. A session matches when its header's Meta (the merged
	// view Load returns) holds every pair. Backends may answer this
	// from the header alone — the cheap path; the title filter below is
	// the one that can cost more.
	Meta map[string]string
	// TitleSearch filters by the session's current title — the last
	// info entry's Title — matching case-insensitively as a substring.
	// A title is entry state, not header state, so this filter is the
	// one shape of List that may read beyond headers (a backend scans
	// the session's info entries): opt-in by the query, priced
	// accordingly, and never paid by a query without it.
	TitleSearch string
}

Query selects sessions for List. The zero value lists every session, newest first, 50 at a time.

Example

Query filters the session list: metadata pairs match the header exactly, and the title search matches the session's current title — the last info entry's — case-insensitively as a substring. Total counts the matches; the cursor and limit page them.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft/thread"
)

func main() {
	ctx := context.Background()
	st := thread.Memory()
	created := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
	titled := func(id string, meta map[string]string, title string) {
		h := thread.Header{ID: id, Created: created, Meta: meta}
		if err := st.Create(ctx, h); err != nil {
			fmt.Println(err)
			return
		}
		if title != "" {
			if err := st.Append(ctx, id, thread.InfoEntry{
				ID: "e_" + id, Created: created, Title: title,
			}); err != nil {
				fmt.Println(err)
			}
		}
	}
	titled("s_prod", map[string]string{"env": "prod"}, "Checkout bug")
	titled("s_dev", map[string]string{"env": "dev"}, "login flow")
	titled("s_prod_2", map[string]string{"env": "prod"}, "checkout again")

	p, err := st.List(ctx, thread.Query{Meta: map[string]string{"env": "prod"}})
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("env=prod:", p.Total)
	p, err = st.List(ctx, thread.Query{TitleSearch: "CHECKOUT"})
	if err != nil {
		fmt.Println(err)
		return
	}
	for _, h := range p.Sessions {
		fmt.Println("title match:", h.ID)
	}
}
Output:
env=prod: 2
title match: s_prod_2
title match: s_prod

type QueuedSteer added in v0.3.0

type QueuedSteer struct {
	// Receipt is the queued receipt entry's id — the handle the
	// steer's Turn reports as its own ID.
	Receipt string
	// Msg is the message held for the running turn's drain.
	Msg weft.Message
}

QueuedSteer is one entry of the session's live steer queue, as Queue reports it: the receipt entry's id and the message held for delivery.

type Reason

type Reason string

Reason is why a compaction ran (ADR 0020 §1): manual (the caller asked), threshold (the configured trigger), trim (a trimmer pre-pass brought the context under the line, so no summary was made), from_hook (BeforeCompact replaced the plan), and overflow — an ErrContextOverflow turn compacted and re-run, which arrives with thread v0.3.

const (
	ReasonManual    Reason = "manual"
	ReasonThreshold Reason = "threshold"
	ReasonTrim      Reason = "trim"
	ReasonFromHook  Reason = "from_hook"
	ReasonOverflow  Reason = "overflow" // v0.3: overflow compaction and re-run (ADR 0020 §5)
)

type ReceiptEntry added in v0.3.0

type ReceiptEntry struct {
	ID       string        `json:"id"`
	ParentID string        `json:"parent,omitempty"`
	Created  time.Time     `json:"created"`
	Receipt  string        `json:"receipt,omitempty"`
	Status   string        `json:"status"`
	Msg      *weft.Message `json:"msg,omitempty"`
	RunID    string        `json:"run_id,omitempty"`
	Turn     string        `json:"turn,omitempty"`
}

ReceiptEntry is the steering receipt (ADR 0019, plan §6): the journey of one message accepted while the session was busy under the Steer policy. One entry records acceptance — Status "queued", the message on Msg — and a second, linked by Receipt, records the fate: "delivered" (the running run's steering drain took it; RunID names the run, and the message landed in that run's transcript), "deferred" (it runs as the next turn instead — a StopWhen end, an open approval boundary, or the run ended before the drain; Turn names the follow-up turn's receipt), or "dropped" (ClearQueue). Receipt entries never enter the model's context: the message reaches the model through the run that delivered it or the follow-up turn that ran it, exactly once.

func (ReceiptEntry) MarshalJSON added in v0.3.0

func (e ReceiptEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator and "v":3.

type Request

type Request struct {
	// Session is the session the request lives in.
	Session string
	// CallID is the pending call's id — the key decisions address and
	// the core's Approve/Deny/Resolve key on.
	CallID string
	// Tool is the call's tool name.
	Tool string
	// Args is the call's arguments, verbatim as the model wrote them.
	Args json.RawMessage
	// ArgsSHA256 is the hex SHA-256 of Args — a decision names what it
	// decided by this hash, and step 2.2's signed decisions carry it in
	// the challenge.
	ArgsSHA256 string
	// RunID is the run that parked the call.
	RunID string
	// Reason is why the call parked: "tool requires approval" for a
	// RequireApproval tool, "middleware required approval" when the
	// tool chain parked it.
	Reason string
	// Expiry is when the request lapses — zero means never. A request
	// is expired strictly after this time, never at it; the next resume
	// denies it with the stated reason (ADR 0021 §5).
	Expiry time.Time
	// Created is when the request was persisted.
	Created time.Time
	// Nonce and KeyID are the challenge a signed decision answers (ADR
	// 0021 §3): set by Session.Request — the signing side's handle on
	// exactly what it vouched for — and empty on the Pending view,
	// which mints no challenge.
	Nonce string
	// Child names the delegated session this call parks in, when the
	// call is a pool child's mirrored onto this parent (ADR 0022 §7):
	// the lineage a decision routes by — decide it through the pool,
	// which resumes the child and then completes the delegation. Empty
	// on an ordinary request.
	Child string
	KeyID string
}

Request is a parked call awaiting a decision (ADR 0021 §1): what the model asked for, hashed and named so a decision can state exactly what it decided. Session.Pending returns the undecided ones; they survive restarts because they are entries.

type SendOption

type SendOption interface {
	// contains filtered or unexported methods
}

SendOption configures one Send. Step 1.7 carries one: RunOptions, the extra run options for this turn's run.

func As added in v0.3.0

func As(p Policy) SendOption

As returns the SendOption overriding the session's busy policy for this one Send (ADR 0019): As(Steer) steers a message into the running turn on a Queue session; As(Queue) holds a message for the next turn on a Steer session. The policy in force is the one Send ran under, captured with the turn's other settings.

Example

As steers one Send into a running turn on a Queue session: the message is accepted at once — its queued receipt durable — and the run's next drain point delivers it after the tool batch, the receipt recording the fate (ADR 0019).

package main

import (
	"context"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	release := make(chan struct{})
	wait := weft.Tool("wait", "blocks until released", func(ctx context.Context, _ struct{}) (string, error) {
		select {
		case <-release:
			return "ok", nil
		case <-ctx.Done():
			return "", ctx.Err()
		}
	})
	model := wefttest.Script(
		wefttest.ToolCalls(wefttest.Call{Name: "wait"}),
		wefttest.Say("done, in metric units"),
	)
	var s *thread.Session
	agent := weft.New(model, wait, weft.Tap(func(_ context.Context, ev weft.Event) {
		if _, ok := ev.(weft.ToolStart); ok {
			if _, err := s.Send(ctx, weft.User("use metric units"), thread.As(thread.Steer)); err != nil {
				fmt.Println(err)
			}
		}
	}))
	s, _ = thread.Create(ctx, thread.Memory(), agent)
	t, _ := s.Send(ctx, weft.User("convert this"))
	close(release)
	res, err := t.Wait()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(res.Text())
	for _, e := range s.Entries() {
		r, ok := e.(thread.ReceiptEntry)
		if !ok {
			continue
		}
		if r.Receipt == "" {
			fmt.Println("receipt queued:", r.Msg.Text()) // acceptance, durable
			continue
		}
		fmt.Println("receipt", r.Status) // the fate, linked by Receipt
	}
}
Output:
done, in metric units
receipt queued: use metric units
receipt delivered

func RunOptions

func RunOptions(opts ...weft.RunOption) SendOption

RunOptions returns the SendOption carrying extra weft.RunOptions into this turn's run — budgets, taps, thinking, an approval resume (weft.Approve, weft.Deny). The transcript is Send's to build and the run id the session's to mint: an option that would set the run's messages (weft.Messages, weft.Prompt) or its id (weft.RunID) is rejected when the Send runs, because either would quietly detach the run from the session's tree (ADR 0011 §4).

type Session

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

A Session is a loaded conversation: the append-only entry tree (ADR 0011 §2) held in memory, every write going through the storage's Append, the leaf tracked as the entry the next one attaches to. Create or Open return one; it is safe for concurrent use by any number of goroutines. The Session is the session's one writer — entries appended to the storage behind its back are invisible to it until the next Open.

func Create

func Create(ctx context.Context, st Storage, agent *weft.Agent, opts ...SessionOption) (*Session, error)

Create starts a new session in st: a fresh header under a new time-sortable id (or the IDs option's), the tree empty. agent is the session's own — Send runs it (step 1.7) and compaction summarizes with its model (step 1.8) — and must not be nil, like the core's New. The header is all that is written: nothing else lands in the storage until the first append.

Example

A session carries the conversation: application state survives outside the model's context, application messages ride inside it, and the title and metadata are editable appends. The IDs option pins deterministic ids so the output is stable.

package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(wefttest.Script()) // no run happens here; Send arrives in step 1.7
	st := thread.Memory()

	next := 0
	ids := []string{"s_demo1", "e_demo2", "e_demo3", "e_demo4", "e_demo5"}
	deterministic := thread.IDs(func() string { id := ids[next]; next++; return id })

	s, err := thread.Create(ctx, st, agent, deterministic)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("session", s.ID())
	if err := s.SetInfo(ctx, "Order support", map[string]string{"team": "ops"}); err != nil {
		fmt.Println(err)
		return
	}
	if err := s.Custom(ctx, "cart", json.RawMessage(`{"items":2}`)); err != nil {
		fmt.Println(err)
		return
	}
	if err := s.CustomMessage(ctx, "note", weft.User("The customer's quote covers two items.")); err != nil {
		fmt.Println(err)
		return
	}

	again, err := thread.Open(ctx, st, s.ID(), agent, thread.IDs(func() string { return "e_demo6" }))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(again.Title(), again.Meta()["team"])
	for _, m := range again.Context() {
		fmt.Println(m.Role, ":", m.Text())
	}
}
Output:
session s_demo1
Order support ops
user : The customer's quote covers two items.

func Open

func Open(ctx context.Context, st Storage, id string, agent *weft.Agent, opts ...SessionOption) (*Session, error)

Open loads an existing session from st, positioned at its leaf: the entry the next append attaches to, recovered by replaying the entries in append order (a trailing leaf entry redirects; any other entry leaves the leaf at itself). A load that had to drop a torn tail or skip a salvaged line reports it once, as a warning through the agent's logger — a repair is never silent (ADR 0011 §5) — and the session still opens: the entries that survived are the session.

func SessionFromContext added in v0.5.0

func SessionFromContext(ctx context.Context) *Session

SessionFromContext returns the session whose run ctx carries it, or nil outside a session's run — a wrapped tool invoked through a bare Generate has no parent session, and the pool falls back to the ordinary subagent path (ADR 0022 §2).

func (*Session) AppendApprovalRequests added in v0.5.0

func (s *Session) AppendApprovalRequests(ctx context.Context, reqs ...ApprovalRequestEntry) ([]ApprovalRequestEntry, error)

AppendApprovalRequests appends mirrored approval requests in one atomic batch (ADR 0022 §7): the pool writes a child session's parked calls onto the parent's tree — Child naming the session they park in, Wrapper the delegating call they park under — so the parent's Pending surfaces them and a decision records like any other. The entries' tree fields are minted here; the stored entries return. Mirrors are ledger until decided: they never join the model's context, and their resolution is the pool's to route.

func (*Session) AppendPoolReceipt added in v0.5.0

func (s *Session) AppendPoolReceipt(ctx context.Context, e PoolReceiptEntry) (PoolReceiptEntry, error)

AppendPoolReceipt appends one pool receipt entry (ADR 0022 §4) and returns it as stored, its minted ID the receipt handle a later entry links back to with Receipt. The pool calls this for every state its delegations pass through — acceptance, the start, the settlement — under the rule the entry kind's contract states: pool receipts are ledger, never model context, and a child's answer reaches the model only through its delegating call's result or the application. A hand caller owns the same rules.

func (*Session) ApplyCompaction

func (s *Session) ApplyCompaction(ctx context.Context, c *Compaction) error

ApplyCompaction writes a computed Compaction as the session's next compaction entry, appended at the leaf. c must name a FirstKept on the leaf's path — a value the walk could never reach is an error, not a fallback — and one at or after the previous compaction's kept boundary: an iterative compaction never summarizes what a summary already replaced (ADR 0020 §1). A summary-less compaction must be a trim. Anything else is an error, and nothing is written. The entry lands whatever else happened between its computation and this call — the tree only grew, so the kept range stays correct.

func (*Session) Audit

func (s *Session) Audit() []Entry

Audit returns the session's approval trail (ADR 0021 §5): every request, chain step, decision, grant and revocation entry, in append order, each carrying its entry id and time — the whole file, not only the leaf's path, because the trail is what happened, not what the leaf remembers. An expiry denial reads as its audit entry plus the decision with Via "expiry"; a grant match as the audit entry naming the grant.

func (*Session) Branch

func (s *Session) Branch(ctx context.Context, entryID string, opts ...BranchOption) error

Branch navigates the session to entryID: it appends a leaf entry (never a rewrite, ADR 0011 §3), and the next write attaches there — the model's context is rebuilt from that path alone, and nothing appended on the abandoned branch reaches it again (pi's invariant). entryID may name any entry the session holds, on any branch, including the current leaf (a recorded no-op navigation); "" is the root, restarting the conversation from nothing while the file keeps everything. An id the session does not hold is an error.

Branching is a between-turns operation: while a turn runs the session holds the line the run's transcript must land on, and a navigation underneath it would strand the run's messages on a branch whose context the model never saw — so a Branch while a turn is in flight fails with ErrBusy, like a Send under the Reject policy. A parked approval boundary is not a running turn: branching away from it is the documented way out of an unwanted boundary (the armed resume reads ErrNotPending).

Example

Branch navigates the tree and Fork copies a path into a new session: the branch's context drops the abandoned entries, the fork carries the whole copied path and names its origin in its header.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(wefttest.Script())
	st := thread.Memory()
	next := 0
	ids := []string{"s_nav", "e_nav"}
	s, err := thread.Create(ctx, st, agent, thread.IDs(func() string { id := ids[next]; next++; return id }))
	if err != nil {
		fmt.Println(err)
		return
	}
	// Two messages of history, appended the way Send will from step 1.7.
	if err := st.Append(ctx, s.ID(),
		thread.MessageEntry{ID: "e_1", Created: time.Now().UTC(), Message: weft.User("draft the intro")},
		thread.MessageEntry{ID: "e_2", ParentID: "e_1", Created: time.Now().UTC(), Message: weft.Assistant("done")},
	); err != nil {
		fmt.Println(err)
		return
	}
	s, err = thread.Open(ctx, st, s.ID(), agent, thread.IDs(func() string { id := ids[next]; next++; return id }))
	if err != nil {
		fmt.Println(err)
		return
	}

	// Navigate back to the first entry: the abandoned reply drops out
	// of the context, and the file keeps it.
	if err := s.Branch(ctx, "e_1"); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("branch context:", len(s.Context()))

	// Fork the full path into a new session: self-contained, its
	// header naming where it grew from.
	f, err := s.Fork(ctx, "e_2", thread.IDs(func() string { return "s_fork" }))
	if err != nil {
		fmt.Println(err)
		return
	}
	page, _ := thread.List(ctx, st, thread.Query{})
	for _, h := range page.Sessions {
		if h.ID == "s_fork" && h.Parent != nil {
			fmt.Println("fork of", h.Parent.Session, "at", h.Parent.Entry, "carries", len(f.Context()), "messages")
		}
	}
}
Output:
branch context: 1
fork of s_nav at e_2 carries 2 messages

func (*Session) ClearQueue added in v0.3.0

func (s *Session) ClearQueue(ctx context.Context) (int, error)

ClearQueue drops every queued steer that has not been delivered, marking each receipt dropped: the messages never reach the model. Steers already delivered or deferred are untouched — their runs own them. It returns how many steers were dropped.

func (*Session) Compact

func (s *Session) Compact(ctx context.Context, opts ...CompactOption) error

Compact computes and applies the session's next compaction in one call: PreviewCompaction, then ApplyCompaction. Nothing is deleted — the summarized entries stay in the file, and the context at the leaf becomes the summary, then the entries from the compaction's first kept entry onward.

Example

Compaction summarizes the old part of a session and keeps the recent part raw — nothing is deleted, and Uncompact branches right back.

ctx := context.Background()
// The same model summarizes; a script makes it deterministic.
rec := &recordingModel{reply: "Goal: ship the order service."}
agent := weft.New(rec)
st := thread.Memory()
s, _ := thread.Create(ctx, st, agent)

// A long history, appended the way Send does.
msgs := []string{strings.Repeat("order ", 12_000), strings.Repeat("invoice ", 12_000), strings.Repeat("refund ", 12_000)}
parent := ""
var batch []thread.Entry
for i, text := range msgs {
	id := fmt.Sprintf("e_%d", i)
	batch = append(batch, thread.MessageEntry{ID: id, ParentID: parent, Created: time.Now().UTC(), Message: weft.User(text)})
	parent = id
}
if err := st.Append(ctx, s.ID(), batch...); err != nil {
	fmt.Println(err)
	return
}
s, err := thread.Open(ctx, st, s.ID(), agent)
if err != nil {
	fmt.Println(err)
	return
}
if err := s.Compact(ctx); err != nil {
	fmt.Println(err)
	return
}
fmt.Println("after Compact:", len(s.Context()), "messages")
fmt.Println("summary rides first:", strings.HasPrefix(s.Context()[0].Text(), "<weft-summary>"))
fmt.Println("kept raw:", len(s.Entries()) > 3)
if err := s.Uncompact(ctx); err != nil {
	fmt.Println(err)
	return
}
fmt.Println("after Uncompact:", len(s.Context()), "messages")
Output:
after Compact: 2 messages
summary rides first: true
kept raw: true
after Uncompact: 3 messages

func (*Session) Context

func (s *Session) Context() []weft.Message

Context returns the messages the model sees at the session's leaf: the message and custom_message entries on the leaf's path, in conversation order, with weft.Repair applied last — every call the transcript shows has a result (ADR 0011 §2, ADR 0001). Entries of the bookkeeping kinds never reach it; a custom entry's whole point is to survive outside it. A call left pending by its turn is shown repaired here — the caller's view; the run Send starts repairs pending calls itself, so the decision options can resolve them.

This walk is where compaction lands: from step 1.8, the latest compaction entry on the path contributes its summary ahead of the entries from its first kept id onward, and a branch_summary entry contributes the abandoned branch's summary in that branch's place (ADR 0020 §1, §6). Nothing before step 1.8 writes either kind, so the walk below reads the whole path raw.

func (*Session) Custom

func (s *Session) Custom(ctx context.Context, kind string, data json.RawMessage) error

Custom appends application state: a caller-chosen Kind and opaque JSON Data, surviving every compaction and never entering the model's context (ADR 0011 §2, ADR 0020 §4). data is stored verbatim — nil stores no data key at all; bytes that are not JSON are rejected, the same failure the storage's Append would raise, caught before anything is written. An empty kind is rejected: state nobody can name is not state.

func (*Session) CustomMessage

func (s *Session) CustomMessage(ctx context.Context, kind string, msg weft.Message) error

CustomMessage appends an application message: a caller-chosen Kind and a weft.Message that is always in the model's context — how an application puts a note the model must see without attributing it to the user (ADR 0011 §2). An empty kind is rejected.

func (*Session) Decide

func (s *Session) Decide(ctx context.Context, ds ...Decision) (*Turn, error)

Decide records decisions over the session's pending calls, durably, and resumes the boundary when they complete it (ADR 0021 §1).

Every decision must address a currently pending call: one that does not fails with ErrNotPending and nothing is recorded — the error is raised before any entry lands and before any run starts, the core's rule made strict. When, after recording, every pending call of the boundary has a decision and AutoResume is on (the default), the resume run starts and Decide returns its Turn; otherwise the return is (nil, nil) and the caller drives Resume. The undecided calls a Resume-driven run carries are denied with the core's "no decision" text; expired ones were denied on the spot with the expiry reason.

Example

Approvals make a parked call durable: the turn ends pending, the request survives a restart as an entry, and Decide records the decision and resumes the conversation on its own (ADR 0021).

package main

import (
	"context"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(
		wefttest.Script(
			wefttest.ToolCalls(wefttest.Call{Name: "refund", ID: "call_1", Args: `{"order_id":"1234"}`}),
			wefttest.Say("Refund issued."),
		),
		weft.Tool("refund", "Refund an order.",
			func(ctx context.Context, in struct {
				OrderID string `json:"order_id"`
			}) (string, error) {
				return "refunded " + in.OrderID, nil
			},
			weft.RequireApproval()),
	)
	next := 0
	ids := []string{"s_appr", "e_1", "e_2", "e_3", "e_4", "e_5", "e_6", "e_7", "e_8", "e_9", "e_10", "e_11"}
	s, _ := thread.Create(ctx, thread.Memory(), agent,
		thread.IDs(func() string { id := ids[next]; next++; return id }))

	t1, err := s.Send(ctx, weft.User("Refund order 1234."))
	if err != nil {
		fmt.Println(err)
		return
	}
	res, err := t1.Wait()
	if err != nil {
		fmt.Println(err) // a pending turn is a success
		return
	}
	fmt.Println("parked:", res.Pending[0].Name)
	for _, r := range s.Pending() {
		fmt.Println("pending request:", r.CallID, "on", r.Tool)
	}

	rt, err := s.Decide(ctx, thread.Approve("call_1"))
	if err != nil {
		fmt.Println(err)
		return
	}
	if _, err := rt.Wait(); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("pending after decide:", len(s.Pending()))
	for _, m := range s.Context() {
		if r, ok := lastResult(m); ok {
			fmt.Println("result:", r)
		}
	}
}

// lastResult reports the message's last tool result text, if any.
func lastResult(m weft.Message) (string, bool) {
	for i := len(m.Content) - 1; i >= 0; i-- {
		if r, ok := m.Content[i].(weft.ToolResultPart); ok {
			return r.Content, true
		}
	}
	return "", false
}
Output:
parked: refund
pending request: call_1 on refund
pending after decide: 0
result: refunded 1234

func (*Session) DecideSigned

func (s *Session) DecideSigned(ctx context.Context, sd SignedDecision) (*Turn, error)

DecideSigned verifies sd fail-closed, then records it as the request's decision — with the nonce and key id, so a replay of the same signature is detectable from the file — and resumes when the boundary completes (AutoResume, like Decide).

The verification order is the error catalogue (ADR 0021 §3): a key the ring does not hold is ErrUnknownKey; a MAC that does not match the canonical encoding of the claimed fields — including the session, the tool, and the outcome — is ErrBadSignature, compared constant-time; a challenge past its expiry is ErrExpired; a nonce a recorded decision already answered is ErrReplay; and a signature whose arguments hash names different arguments than the pending request holds is ErrArgsChanged — the call re-parked with new arguments, and the decision was about the old ones. Nothing is recorded until every check passes; ErrNotPending keeps Decide's rule, raised before any run starts.

func (*Session) Entries

func (s *Session) Entries() []Entry

Entries returns the session's whole tree in append order — every entry ever written, abandoned branches included. The slice is fresh and the entries' bookkeeping fields are copies: mutating what comes back never reaches the session or the storage.

func (*Session) Fork

func (s *Session) Fork(ctx context.Context, entryID string, opts ...SessionOption) (*Session, error)

Fork copies the session's path root → entryID into a new session in the same storage, and returns it positioned at entryID: the new session is self-contained — its file holds the whole copied path with the entries' own ids and parent links — and traceable, its header naming the session and entry it grew from (ADR 0011 §3). entryID follows Branch's rule: any held entry, or "" for the root (a fork of an empty session is an empty session with a parent). opts configure the new session, not this one: its ids option mints the fork's session id and its later entries; the copied entries keep the ids they were born with. The fork starts with no title or metadata — those belong to the original's info entries.

func (*Session) Grant

func (s *Session) Grant(ctx context.Context, g Grant) error

Grant appends a session-scoped grant (ADR 0021 §4): the decision chain consults it — newest grant first — for every call about to park, from the next boundary on. The tool name must not be empty: a grant that matches every tool is a policy this shape does not have.

func (*Session) ID

func (s *Session) ID() string

ID returns the session's id — the header's, and the storage key.

func (*Session) Label

func (s *Session) Label(ctx context.Context, entryID, name string) error

Label names an entry — bookmarks, checkpoints, the anchors a UI lists. The label is an appended entry, never a rewrite, and never enters the model's context. entryID must name an entry the session holds; the name must not be empty.

func (*Session) Leaf

func (s *Session) Leaf() string

Leaf returns the entry the next appended entry attaches to: the id of the last entry, the target of a trailing leaf entry, or "" while the session holds no entries (the next entry is the root).

func (*Session) Lineage added in v0.5.0

func (s *Session) Lineage() Lineage

Lineage returns the session's pool lineage (ADR 0022 §3): the parent session and delegating call it was started from, as recorded in its header at Create. The zero value means the session is nobody's child.

func (*Session) Meta

func (s *Session) Meta() map[string]string

Meta returns the session's current metadata as a fresh map the caller owns: the header's Meta as the base layer, overlaid by every info entry's Meta in append order. nil before anything set any.

func (*Session) Path

func (s *Session) Path(entryID string) ([]Entry, error)

Path returns the entries from the root to entryID, inclusive, in conversation order — the chain of parent links. entryID "" is the root and returns no entries; an id the session does not hold is an error. Path names tree structure: a leaf entry's own parent is where it was appended, not the entry it navigated to.

func (*Session) Pending

func (s *Session) Pending() []Request

Pending returns the session's undecided requests, in call order: the calls the leaf's path leaves dangling that have an approval request and no decision yet. It works after a restart — requests and decisions are entries — and it never shows a call whose boundary a resume already resolved.

func (*Session) Pin

func (s *Session) Pin(ctx context.Context, entryID string) error

Pin marks an entry to survive every compaction raw — a requirement, a key decision — by appending a pin record (a custom entry, so it survives compaction itself, the way everything custom does). The cut moves forward past pinned entries: no compaction summarizes one.

Example

Pin keeps an entry in the context through every compaction — the requirement, the key decision — recorded as a reserved custom entry that survives compaction the way all custom state does.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(wefttest.Script())
	st := thread.Memory()
	s, _ := thread.Create(ctx, st, agent)
	if err := st.Append(ctx, s.ID(), thread.MessageEntry{
		ID: "e_req", Created: time.Now().UTC(),
		Message: weft.User("THE REQUIREMENT: ship by Friday"),
	}); err != nil {
		fmt.Println(err)
		return
	}
	open, _ := thread.Open(ctx, st, s.ID(), agent)
	if err := open.Pin(ctx, "e_req"); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("pinned")
}
Output:
pinned

func (*Session) PreviewCompaction

func (s *Session) PreviewCompaction(ctx context.Context, opts ...CompactOption) (*Compaction, error)

PreviewCompaction computes the session's next compaction without writing anything: the cut, the summarized range, the summary text, and the entry fields a Compact would record. It runs the summarizer — the session's own model (ADR 0020 §2) — with the skeleton prompt, the previous summary (iterative compaction) as the range's first message, prompt-cache writes left off (the request carries no cache hints) and the output capped at 0.8 × Reserve. A session whose tail fits inside KeepRecent has nothing to compact and gets an error, not a no-op entry.

func (*Session) Queue added in v0.3.0

func (s *Session) Queue() []QueuedSteer

Queue returns the session's live steer queue: the messages accepted under the Steer busy policy that are waiting for the running turn's drain, in acceptance order. Delivered and deferred steers are not listed — their stories live on the receipt entries (Entries).

func (*Session) Request

func (s *Session) Request(callID string) (Request, error)

Request returns the pending request for callID with a fresh challenge minted under the keyring's active key: a nonce this session has never issued, the key's id, and everything a signer needs to vouch for a decision over exactly this request (ADR 0021 §3). The nonce makes the challenge single-use — DecideSigned records it, and a replay of the same signature fails with ErrReplay, across restarts, because nonces are entries.

func (*Session) Resume

func (s *Session) Resume(ctx context.Context) (*Turn, error)

Resume starts the boundary's resume run now (ADR 0021 §1): the decided calls resolve under their decisions — approved calls run with Call.Approved set, denied ones show their reason, resolved ones their content — and every undecided call is denied with the core's "no decision" text. Requests strictly past their expiry were denied on the spot, with the stated reason, before the run starts. The parked boundary must still be open: Resume with nothing to resume fails with ErrNotPending. The returned Turn is the resume run's receipt and handle, like any Send's.

func (*Session) Revoke

func (s *Session) Revoke(ctx context.Context, grantID string) error

Revoke ends a session-scoped grant with an entry (ADR 0021 §4): revocation is an append — the grant entry stays, the walk reads the revocation after it. grantID must name a grant entry the session holds; a shared grant's revocation is its store's own operation.

func (*Session) Send

func (s *Session) Send(ctx context.Context, msg weft.Message, opts ...SendOption) (*Turn, error)

Send appends the prompt and runs the session's agent on the leaf's context, returning the turn's receipt at once. The prompt entry is appended and flushed before the run starts (ADR 0011 §4: accepted input is durable input), the run carries the session's context plus msg and the run id <session>-t<n>, and at the end — success, failure or cancellation — the run's new messages and a turn entry are appended in one atomic batch under a context.WithoutCancel window, so a caller who walks away still leaves a complete session behind.

Settings are captured at turn start: the agent, the extra run options, and the busy policy in force when Send ran. On a busy session the policy decides — Queue (the default) holds the follow-up, in order, and Reject fails with ErrBusy.

A Send while approval requests are pending is queued, not run (ADR 0021 §5): the parked boundary must resolve first — Decide (which resumes on its own when AutoResume is on) or Resume — and the queued follow-up then runs with the boundary's transcript completed. Under Reject a pending boundary reads as busy.

Example

Send runs a turn: the prompt is durable before the run starts, the reply and the turn's ledger land after it, and a reopen sees the whole conversation.

package main

import (
	"context"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	// A scripted model makes the example deterministic and offline.
	agent := weft.New(wefttest.Script(wefttest.Say("It shipped Tuesday."), wefttest.Say("Order 1234, two items.")))
	st := thread.Memory()
	next := 0
	ids := []string{"s_demo", "e_1", "e_2", "e_3", "e_4", "e_5", "e_6"}
	s, _ := thread.Create(ctx, st, agent, thread.IDs(func() string { id := ids[next]; next++; return id }))

	t1, err := s.Send(ctx, weft.User("Where is order 1234?"))
	if err != nil {
		fmt.Println(err)
		return
	}
	res, err := t1.Wait()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("run", t1.RunID(), "replied:", res.Text())

	t2, err := s.Send(ctx, weft.User("And what was in it?"))
	if err != nil {
		fmt.Println(err)
		return
	}
	if _, err := t2.Wait(); err != nil {
		fmt.Println(err)
		return
	}
	again, err := thread.Open(ctx, st, s.ID(), agent)
	if err != nil {
		fmt.Println(err)
		return
	}
	for _, m := range again.Context() {
		if m.Text() != "" {
			fmt.Println(m.Role, ":", m.Text())
		}
	}
}
Output:
run s_demo-t1 replied: It shipped Tuesday.
user : Where is order 1234?
assistant : It shipped Tuesday.
user : And what was in it?
assistant : Order 1234, two items.

func (*Session) SetInfo

func (s *Session) SetInfo(ctx context.Context, title string, meta map[string]string) error

SetInfo edits the session's title and metadata as an appended info entry, never a rewrite of the header (ADR 0011 §2): the current title is the last info entry's Title, the current metadata every info entry's Meta merged in append order — Title and Meta resolve them (an empty title keeps the current one; a nil meta map changes nothing; a later entry overwrites the keys it names and leaves the rest).

func (*Session) Storage added in v0.5.0

func (s *Session) Storage() Storage

Storage returns the storage the session writes through — the handle thread/pool holds to create child sessions in the same place (ADR 0022 §3). It is the app's own storage returned, read-only by convention: every write goes through a Session, never behind one.

func (*Session) Title

func (s *Session) Title() string

Title returns the session's current title — the last info entry's (SetInfo's rule), "" before any SetInfo named one.

func (*Session) Uncompact

func (s *Session) Uncompact(ctx context.Context) error

Uncompact undoes the latest compaction on the leaf's path the only way the format allows: a branch back to the entry before the compaction entry (ADR 0020 §1) — a leaf entry, no rewrite, nothing deleted. The context is exactly what it was before the compaction ran; the compaction and everything after it stay in the file, off the path.

func (*Session) Usage

func (s *Session) Usage() Usage

Usage returns the session's cost ledger over every entry in the file, not only the leaf's path: turns from the per-turn ledger, summarizer costs in their own bucket (ADR 0011 §2, ADR 0020 §4).

Example

The tree in memory and the session in storage agree, and the leaf is where the next entry attaches: Entries walks the whole tree, Path one root-to-entry chain, and Usage reads the cost ledger.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(wefttest.Script())
	st := thread.Memory()
	s, err := thread.Create(ctx, st, agent, thread.IDs(func() string { return "s_ledger" }))
	if err != nil {
		fmt.Println(err)
		return
	}
	// A finished turn, recorded the way Send will from step 1.7.
	if err := st.Append(ctx, s.ID(),
		thread.MessageEntry{ID: "e_q", Created: time.Now().UTC(), Message: weft.User("Summarize the plan.")},
		thread.TurnEntry{
			ID: "e_t", ParentID: "e_q", Created: time.Now().UTC(),
			RunID: "s_ledger-t1", StopReason: weft.StopEndTurn,
			Usage: weft.Usage{InputTokens: 120, OutputTokens: 30},
		},
	); err != nil {
		fmt.Println(err)
		return
	}
	open, err := thread.Open(ctx, st, "s_ledger", agent)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("entries:", len(open.Entries()), "leaf:", open.Leaf())
	fmt.Println("path:", len(mustPath(open, "e_t")))
	u := open.Usage()
	fmt.Println("turn input tokens:", u.Turns.InputTokens, "summary tokens:", u.Summaries.InputTokens)
}

func mustPath(s *thread.Session, id string) []thread.Entry {
	path, err := s.Path(id)
	if err != nil {
		panic(err)
	}
	return path
}
Output:
entries: 2 leaf: e_t
path: 2
turn input tokens: 120 summary tokens: 0

type SessionOption

type SessionOption interface {
	// contains filtered or unexported methods
}

SessionOption configures a session at Create or Open — one value per concern, folded over the defaults, the constructor convention the core set (ADR 0011 §6 borrows it). Options later steps add (busy policy and run options in step 1.7, the compaction layers in steps 1.8–1.9) join the same interface.

func AfterCompact

func AfterCompact(fn func(ctx context.Context, e CompactionEntry)) SessionOption

AfterCompact returns the SessionOption setting the hook that runs after a compaction entry lands, with the entry — the durable record, not the plan.

func ApproverTimeout

func ApproverTimeout(d time.Duration) SessionOption

ApproverTimeout bounds the chain's live consultation: the Approver runs under a context with this deadline, and a timeout or panic reads as a decline, audited, before the call parks. Values <= 0 leave the Approver unconsulted — set one to arm WithApprover.

func AutoResume

func AutoResume(on bool) SessionOption

AutoResume sets whether the session resumes on its own once every pending call of the parked boundary has a decision (ADR 0021 §1). The default is on; AutoResume(false) parks the decided boundary until the caller calls Resume. Either way a plain Send never runs while a boundary is open — only the resume run resolves it.

func BeforeCompact

func BeforeCompact(fn func(ctx context.Context, p *Preparation) (Verdict, error)) SessionOption

BeforeCompact returns the SessionOption setting the hook that sees every computed compaction before it is written, told the reason, and decides: Proceed, Cancel, or Replace with a hook-made summary. A hook error fails the compaction with that error.

func BusyPolicy

func BusyPolicy(p Policy) SessionOption

BusyPolicy returns the SessionOption setting what Send does when the session is busy: Queue (the default), Reject, Steer, Interrupt, or Rollback. A single Send overrides it with As.

Example

A session under the Reject busy policy says ErrBusy instead of holding a follow-up while a turn runs.

package main

import (
	"context"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	agent := weft.New(wefttest.Script())
	s, _ := thread.Create(ctx, thread.Memory(), agent, thread.BusyPolicy(thread.Reject))
	_ = s
	// With a turn in flight (a blocking tool, say):
	//   if _, err := s.Send(ctx, weft.User("one more thing")); errors.Is(err, thread.ErrBusy) { … }
	fmt.Println("Queue is the default; Reject is one option away")
}
Output:
Queue is the default; Reject is one option away

func CheckSummary

func CheckSummary(fn func(Summary) error) SessionOption

CheckSummary returns the SessionOption validating each summary. A failure retries the same model once, then falls back down the chain (SummaryModel → session model → no compaction, reported through CompactFailed) — the headings-present, length-floor checks a careful caller writes.

func ClearOldToolResults

func ClearOldToolResults(keepLast int) SessionOption

ClearOldToolResults returns the SessionOption turning on the built-in trimmer: every tool result in the context except the last keepLast is replaced, at compaction time, with the stub naming the call. If the trimmed context fits the window, no summary is made and a trim record lands in the compaction entry instead.

func CompactFailed

func CompactFailed(fn func(ctx context.Context, r Reason, err error)) SessionOption

CompactFailed returns the SessionOption setting the hook that runs when a compaction fails after every fallback — the session is unchanged, and the caller is told why, with the reason it was attempted for.

func ContextWindow

func ContextWindow(n int64) SessionOption

ContextWindow returns the SessionOption setting the context window the trigger budgets against, in tokens. Zero or negative is ignored (the window stays unknown: no automatic compaction, one warning). ModelWindows overrides it per model.

func Disabled

func Disabled() SessionOption

Disabled returns the SessionOption turning automatic compaction off entirely — no trigger, no warning: the session that compacts only by hand. Manual Compact, PreviewCompaction and Uncompact are unaffected (ADR 0020 §3).

func IDs

func IDs(id func() string) SessionOption

func KeepRecent

func KeepRecent(n int64) SessionOption

KeepRecent returns the SessionOption setting roughly how many estimated tokens of the tail stay raw on either side of the cut. Zero or negative keeps the default 20,000.

func MaxPerSession

func MaxPerSession(n int) SessionOption

MaxPerSession returns the SessionOption capping how many automatic compactions a session may run — zero (the default) means no cap; after the cap the trigger stops firing and manual Compact keeps working.

func MinTurnsBetween

func MinTurnsBetween(n int) SessionOption

MinTurnsBetween returns the SessionOption rate-limiting automatic compaction: no automatic compaction within n turns of the last one (manual Compact always works). Zero, the default, means no limit. The stop against a context that re-crosses the line every turn.

func ModelReserves

func ModelReserves(reserves map[weft.ModelInfo]int64) SessionOption

ModelReserves returns the SessionOption setting per-model reserves, keyed by ModelInfo, overriding Reserve for this session's model — the small-window model that needs more headroom than the default.

func ModelWindows

func ModelWindows(windows map[weft.ModelInfo]int64) SessionOption

ModelWindows returns the SessionOption setting per-model context windows, keyed by ModelInfo (provider + name): the session's own model is looked up at Create/Open, and its entry overrides ContextWindow for this session — pi's per-model settings, for the agent that switches models mid-project.

func OnRequest

func OnRequest(fn func(Request)) SessionOption

OnRequest sets the notification fired when a request parks (ADR 0021 §5): called after the request entry is durable, once per parked call, so an application can push it anywhere. The session does no I/O of its own for this; a panic in fn is contained and logged, never failing the turn. A nil fn is ignored.

func PreferNative

func PreferNative() SessionOption

PreferNative returns the SessionOption asking thread to use the provider's own compaction when the summarizer model (or the one it wraps) implements NativeCompactor — found by following Unwrap() through middleware. The returned message's text is stored as the summary and the entry records the model that made it. On any other model, a switch, or an error, the text summary runs: the seam, with the fallback.

func Quorum

func Quorum(n int) SessionOption

Quorum sets how many decisions from distinct approver identities a call needs before it resolves (ADR 0021 §5): n approvals with different Who values. Values below 2 read as the default — one decision resolves. Conflicting decisions resolve to deny, with the pinned reason.

func ReRunOnOverflow added in v0.3.0

func ReRunOnOverflow(on bool) SessionOption

ReRunOnOverflow sets whether a turn that fails with weft.ErrContextOverflow compacts (reason overflow) and re-runs once over the shrunken path (ADR 0020 §5) — on by default. A second overflow fails the turn with both errors joined; with the re-run off, the first overflow fails it directly.

Example

ReRunOnOverflow arms the overflow recovery (ADR 0020 §5): a turn failing with weft.ErrContextOverflow compacts — reason overflow — and runs once more over the shrunken path, so the caller sees one turn with the re-run's answer.

package main

import (
	"context"
	"fmt"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/wefttest"
)

func main() {
	ctx := context.Background()
	model := wefttest.Script(
		wefttest.Say("a first answer"),                  // a prior turn, so the compaction has a cut
		wefttest.Fail(weft.ErrContextOverflow),          // the overflowing request
		wefttest.Say("the summary of what came before"), // the compaction's summarizer call
		wefttest.Say("recovered after compaction"),      // the re-run
	)
	s, _ := thread.Create(ctx, thread.Memory(), weft.New(model), thread.KeepRecent(1))
	t0, _ := s.Send(ctx, weft.User("a first question"))
	if _, err := t0.Wait(); err != nil {
		fmt.Println(err)
		return
	}
	t1, _ := s.Send(ctx, weft.User("a prompt that overflows"))
	res, err := t1.Wait()
	fmt.Println(err)
	fmt.Println(res.Text())
}
Output:
<nil>
recovered after compaction

func RequestExpiry

func RequestExpiry(d time.Duration) SessionOption

RequestExpiry gives every request the session parks a lifetime: a request strictly past Created plus d is denied with the stated reason on the next resume (ADR 0021 §5). Values <= 0 (the default) mean requests never expire.

func RequireSigned

func RequireSigned() SessionOption

RequireSigned returns the SessionOption that closes the in-process door: Decide — the unsigned path — is rejected with ErrSignatureRequired, and only DecideSigned records decisions (the session's own machinery — the Approver, grants, expiry — still records, with its own audit trail; it is the caller-held unsigned Decide that closes).

func Reserve

func Reserve(n int64) SessionOption

Reserve returns the SessionOption setting the trigger's headroom: compaction fires before the context is within Reserve tokens of the window. Zero or negative keeps the default 16,384.

func SummaryFocus

func SummaryFocus(focus string) SessionOption

SummaryFocus returns the SessionOption appending instructions to the summary prompt (a replacement or the skeleton): "keep every file path" — the caller's standing concern, appended after the skeleton's own rules.

func SummaryMaxTokens

func SummaryMaxTokens(n int) SessionOption

SummaryMaxTokens returns the SessionOption overriding the summarizer output cap (the default 0.8 × Reserve).

func SummaryModel

func SummaryModel(m weft.Model) SessionOption

SummaryModel returns the SessionOption setting a different model for summaries — a cheap one. On failure it falls back to the session's own model (the chain: SummaryModel → session model → no compaction, reported through CompactFailed), because a weak summary costs more in rework than a cheap model saves only when it works at all.

func SummaryPrompt

func SummaryPrompt(tmpl string) SessionOption

SummaryPrompt returns the SessionOption replacing the skeleton system prompt whole. The replacement's bytes are the caller's; the default skeleton stays pinned by its golden.

func TriggerFunc

func TriggerFunc(fn func(TriggerInput) bool) SessionOption

TriggerFunc returns the SessionOption replacing the trigger's condition — fire when in says so. It is consulted only when a window is known and a provider-reported input exists: the no-estimated- signal rule stands under a custom trigger too.

func WithApprover

func WithApprover(a Approver) SessionOption

WithApprover sets the decision chain's live step (ADR 0021 §2). The Approver is consulted for a call about to park, after grants and before parking; it is armed only when ApproverTimeout is positive — the default of no timeout means no waiting, so a headless session never blocks on it (the ADR's "default 0 = no waiting when not interactive"). A nil approver is ignored.

func WithCompactor

func WithCompactor(c Compactor) SessionOption

WithCompactor returns the SessionOption replacing the whole algorithm.

func WithEstimator

func WithEstimator(est Estimator) SessionOption

WithEstimator returns the SessionOption replacing the token estimator. (The ADR sketches thread.Estimator(est); the interface already owns that name, so the option takes the With* shape its layer-3 neighbours use.)

func WithGrantStore

func WithGrantStore(gs GrantStore) SessionOption

WithGrantStore returns the SessionOption adding the shared grant scope (ADR 0021 §4): grants many sessions consult, application-wide, behind the interface the application owns. The chain consults it after the session's own grants. A nil store is ignored.

func WithKeyring

func WithKeyring(r *Keyring) SessionOption

WithKeyring returns the SessionOption making the session verify signed decisions under r — and mint signed challenges from r's active key (s.Request). A nil ring is ignored.

func WithLineage added in v0.5.0

func WithLineage(parentSession, call string) SessionOption

WithLineage sets a created session's pool lineage (ADR 0022 §3): the parent session and the delegating call it grew from. A Create-time option — the header is immutable once written; Open reads the lineage from the file and ignores it.

func WithMeta added in v0.5.0

func WithMeta(meta map[string]string) SessionOption

WithMeta adds keys to a created session's header metadata — the caller metadata List surfaces (ADR 0011 §1). A Create-time option, merged over earlier ones; SetInfo is the later-life edit.

func WithSummarizer

func WithSummarizer(s Summarizer) SessionOption

WithSummarizer returns the SessionOption replacing text production — just the text; the cut and the entry stay the session's.

func WithTrimmer

func WithTrimmer(t Trimmer) SessionOption

WithTrimmer returns the SessionOption setting a custom trimmer. The default trimmer is off; ClearOldToolResults turns a built-in one on.

type SharedGrant

type SharedGrant struct {
	ID string
	Grant
}

A SharedGrant is one grant of a GrantStore, with the store's id for the audit trail.

type SignedDecision

type SignedDecision struct {
	Session    string
	CallID     string
	Tool       string
	ArgsSHA256 string
	Expiry     time.Time
	Kind       Outcome
	Reason     string
	Content    string
	Who        string
	// Always approves and grants the same thing for the future, the
	// signed ApproveAlways (Decision.Always carries it through
	// SignDecision).
	Always bool
	Nonce  string
	KeyID  string
	MAC    []byte
}

SignedDecision is a decision that crossed a process boundary (ADR 0021 §3): what the client decided over the challenge it was handed, vouched by the MAC the challenge's key computed. Build one with SignDecision on the signing side; hand it to Session.DecideSigned on the session side. The fields are the challenge's claims — the verifier checks them against the pending request, so a stale or retargeted signature fails loudly, each way its own error.

func SignDecision

func SignDecision(key []byte, r Request, d Decision) SignedDecision

SignDecision signs d over r's challenge under key — the client-side half of the exchange. r is the Request the session minted (its Nonce and KeyID name the challenge); d is what the human decided; the MAC covers the challenge fields and the decision together, so neither can be altered in flight without failing verification.

type Storage

type Storage interface {
	// Create writes a session's header as its first line. The header's
	// ID must satisfy ValidID — an id is a path component in some
	// backend, so every backend vets it — a zero Weft means the current
	// FormatVersion, any other envelope is rejected, and creating a
	// session that already exists fails with ErrExists: a session is
	// never silently replaced.
	Create(ctx context.Context, h Header) error

	// Append adds entries to a session in arrival order, after the
	// entries it already holds. Appending several entries is atomic —
	// all or none become visible: they are validated (an entry must
	// encode) before anything is written, and a backend that cannot
	// write the whole batch writes none of it. Appending to a session
	// the storage does not hold fails with ErrNotFound.
	Append(ctx context.Context, session string, entries ...Entry) error

	// Load returns a session's header and every entry in append order,
	// and a LoadReport naming anything the load had to drop or skip —
	// nil when the load was clean. The returned values never alias the
	// storage: they are the caller's to keep and to mutate. An unknown
	// session fails with ErrNotFound; data the storage cannot decode
	// fails loudly — ErrNewerFormat for the unknown kind and the newer
	// version, ErrCorrupt for the malformed line (ADR 0011 §5).
	Load(ctx context.Context, session string) (Header, []Entry, *LoadReport, error)

	// List returns a page of session headers — headers only, never
	// entries: a list body that read whole sessions would be the
	// storage bloat every surveyed store had to walk back (ADR 0010's
	// listTracesLight lesson, the same shape here). See Query for the
	// cursor and the limit.
	List(ctx context.Context, q Query) (Page, error)

	// Delete removes a session and its entries; an unknown session
	// fails with ErrNotFound. History is otherwise forever — nothing
	// else in the interface removes data.
	Delete(ctx context.Context, session string) error
}

Storage is a session backend: Memory in process, jsonl on disk, sqlite later — all running the threadtest conformance table. Implementations must be safe for concurrent use: one writer per session is the policy (ADR 0011 §5), and readers may read at any time. context.Context comes first in every signature; a canceled context fails the call before anything is written.

func Memory

func Memory() Storage

Memory returns a ready-to-use in-process session storage: sessions in a map behind a mutex, gone when the process is. For tests, examples, and as the reference behaviour the durable backends are compared against — every backend runs the same threadtest table, corruption rows included (Memory implements the table's threadtest.RawInjector hook by holding the raw bytes).

Example

Memory is the in-process Storage: create a session, append entries, load them back — the shape every backend shares (threadtest pins the rest).

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
)

func main() {
	ctx := context.Background()
	st := thread.Memory()
	h := thread.Header{
		ID:      "s_demo",
		Created: time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC),
		Meta:    map[string]string{"room": "table-1"},
	}
	if err := st.Create(ctx, h); err != nil {
		fmt.Println(err)
		return
	}
	if err := st.Append(ctx, h.ID, thread.MessageEntry{
		ID:      "e_demo1",
		Created: h.Created.Add(time.Second),
		Message: weft.User("Where is order 1234?"),
	}); err != nil {
		fmt.Println(err)
		return
	}
	header, entries, report, err := st.Load(ctx, h.ID)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(header.ID, header.Meta["room"], len(entries), report)
	for _, e := range entries {
		switch e := e.(type) {
		case thread.MessageEntry:
			fmt.Println(e.Message.Role, "asks:", e.Message.Text())
		}
	}
}
Output:
s_demo table-1 1 <nil>
user asks: Where is order 1234?

type Summarizer

type Summarizer interface {
	Summarize(ctx context.Context, in SummaryInput) (Summary, error)
}

Summarizer produces summary text — just the text: the cut, the serialization and the entry are the session's. Implemented over a model by default (the session's own, or SummaryModel).

type Summary

type Summary struct {
	Text   string
	Reason Reason
	// Usage and Model are what the summary cost and which model made
	// it — the cost ledger's inputs; a custom Summarizer that runs no
	// model leaves them zero.
	Usage weft.Usage
	Model weft.ModelInfo
}

Summary is a summarizer's output: the text, and the reason it ran.

type SummaryInput

type SummaryInput struct {
	Messages     []weft.Message
	PrevSummary  string
	Instructions string
	MaxTokens    int
	Reason       Reason
	SystemPrompt string
	SummaryModel weft.Model
}

SummaryInput is everything a Summarizer needs: the range already serialized for summarizing (tool results capped, signed reasoning dropped, files as names), the previous summary when one exists, the per-call instructions, the output cap, and the reason it runs.

type TriggerInput

type TriggerInput struct {
	LastInput int64
	Estimated int64
	Window    int64
	Reserve   int64
}

TriggerInput is what a TriggerFunc decides on: the provider-reported input of the last model step, the estimated tokens added since, and the window and reserve in force. The reported number is the signal; only the delta is estimated (ADR 0020 §2).

type TrimReport

type TrimReport struct {
	Cleared       int
	EstimatedSave int64
}

TrimReport says what a trimmer did: how many tool results it replaced with the stub, and roughly how many estimated tokens that saved.

type Trimmer

type Trimmer interface {
	Trim(ctx context.Context, msgs []weft.Message) ([]weft.Message, TrimReport)
}

Trimmer is the cheap pre-pass: before summarizing, replace old tool results with a stub naming the call, and the context may fit again (Anthropic clear_tool_uses, Vercel pruneMessages). It runs on the automatic path only — a manual Compact summarizes.

type Turn

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

A Turn is the receipt and the handle of one accepted Send — or of a resume run over a parked approval boundary, which has no prompt of its own: its receipt names its turn entry.

func (*Turn) Events

func (t *Turn) Events() iter.Seq2[weft.Event, error]

Events yields the turn's run events in emission order, forwarded by the session as it observes the run. Unlike the core's Run, a Turn's Events may be ranged more than once — the session, not the consumer, drives the run, so abandoning the stream mid-way (breaking out of the range) never cancels it; the turn's end wakes a suspended range and ends it. A failed run delivers its error exactly once as the final element, the core's rule.

func (*Turn) ID

func (t *Turn) ID() string

ID returns the turn's receipt: the prompt entry's id, minted when the Send was accepted and durable before the run started — or, for a resume turn, its turn entry's id, reused from the mint at arming (a resume writes no prompt of its own). Looking a turn up in the tree starts here.

func (*Turn) Next

func (t *Turn) Next() *Turn

Next returns the turn the session resumed this turn's parked approval boundary with, or nil while there is none — this turn did not park, its boundary resumed under a turn the caller already holds (Decide and Resume return it), or the boundary is still undecided. Following Next is how a caller watches an approval flow through: Send's turn parks; the auto-resume the decision chain or a completed decision set starts links here, and its Wait is the conversation's continuation. One boundary resumes at most once, so the link is set at most once.

func (*Turn) RunID

func (t *Turn) RunID() string

RunID returns the run's id, <session>-t<n> — the key the run store holds the run's records under, unique across a reopen. A turn that re-ran after an overflow reports the re-run's id (the run that produced its outcome).

func (*Turn) Wait

func (t *Turn) Wait() (*weft.RunResult, error)

Wait blocks until the turn ends and returns the run's result and error — *weft.RunError on failure, its Result carrying the partial transcript, exactly as the core's Wait reports it. The session drains the run itself, so Wait alone observes every event; it is safe to call after or while ranging over Events.

type TurnEntry

type TurnEntry struct {
	ID         string              `json:"id"`
	ParentID   string              `json:"parent,omitempty"`
	Created    time.Time           `json:"created"`
	RunID      string              `json:"run_id"`
	StopReason weft.StopReason     `json:"stop_reason,omitempty"`
	Usage      weft.Usage          `json:"usage"`
	Steps      int                 `json:"steps,omitempty"`
	Err        string              `json:"err,omitempty"`
	Pending    []weft.ToolCallPart `json:"pending,omitempty"`
	Canceled   bool                `json:"canceled,omitempty"`
	// LastInput is the compaction trigger's baseline for the turn:
	// the provider-reported input of the run's final model step, plus
	// the estimated tokens of the tail that report cannot cover (the
	// final step's own messages) — one number, recovered identically
	// by a live session and a reopen (ADR 0020 §2: reported tokens
	// are the signal; only what the report cannot cover is estimated).
	// Absent on turns that ran no step.
	LastInput int64 `json:"last_input,omitempty"`
}

TurnEntry is the per-turn ledger: the run's id (<session>-t<n>), its stop reason, usage and step count, the error text on failure, the calls left pending on the approval boundary, and whether the turn was canceled. It records the turn's outcome, never its content — the messages are MessageEntries — and it does not enter the model's context.

func (TurnEntry) MarshalJSON

func (e TurnEntry) MarshalJSON() ([]byte, error)

MarshalJSON encodes the entry with its "type" discriminator.

type Usage

type Usage struct {
	// Turns sums every turn entry's usage — every run the session ever
	// made, abandoned branches included: the tokens were spent.
	Turns weft.Usage
	// Summaries sums the summarizer usage of every compaction entry —
	// the cost of keeping the context small, in its own bucket.
	Summaries weft.Usage
	// Delegated sums the usage every settled pool receipt carries —
	// the cost of work handed to thread/pool children (ADR 0022 D3),
	// in its own bucket: a delegated child's tokens are not this
	// session's turns, and the ledger never mixes kinds.
	Delegated weft.Usage
}

Usage is a session's cost ledger (ADR 0020 §4): what its turns cost and what its summaries cost, never mixed.

type Verdict

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

Verdict is what a BeforeCompact hook returns: proceed with the computed compaction, cancel it, or replace it with a hook-made one (recorded from_hook).

func Replace

func Replace(c *Compaction) Verdict

Replace writes c instead — the hook's summary, recorded from_hook.

type Watcher

type Watcher interface {
	Watch(ctx context.Context, session string, after string) (iter.Seq2[Entry, error], error)
}

Watcher is the optional Storage capability that tails a session as it is appended to — the same small-interface rule. It arrives in v0.4 (plan §7, the live tail); it is declared now, unimplemented, so the capability pattern and its vocabulary ship with the first release instead of being retrofitted. Watch yields the session's entries in arrival order, starting after the entry named by after (empty — from the beginning), and ends when ctx is done; a session the storage does not hold fails with ErrNotFound before the first yield. Readers never lock: a watcher is a reader that waits.

Example

A watcher tails a session as it is appended to — the live tail a second process reads while the first writes. The stream yields the backlog in arrival order, then each new entry exactly once, and ends when its context is canceled.

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/thread/jsonl"
)

func main() {
	ctx := context.Background()
	dir, err := os.MkdirTemp("", "weft-watch")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	st, err := jsonl.Open(dir)
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := st.Create(ctx, thread.Header{ID: "s_demo", Created: time.Now().UTC()}); err != nil {
		fmt.Println(err)
		return
	}
	if err := st.Append(ctx, "s_demo",
		thread.MessageEntry{ID: "e_1", Created: time.Now().UTC(), Message: weft.User("one")}); err != nil {
		fmt.Println(err)
		return
	}
	watch := st.(thread.Watcher)
	wctx, cancel := context.WithCancel(ctx)
	defer cancel()
	seq, err := watch.Watch(wctx, "s_demo", "")
	if err != nil {
		fmt.Println(err)
		return
	}
	go func() {
		for e, err := range seq {
			if err != nil {
				return
			}
			fmt.Println("watched:", e.(thread.MessageEntry).Message.Text())
		}
	}()
	if err := st.Append(ctx, "s_demo",
		thread.MessageEntry{ID: "e_2", Created: time.Now().UTC(), Message: weft.User("two")}); err != nil {
		fmt.Println(err)
		return
	}
	time.Sleep(600 * time.Millisecond) // let the tail catch both
	cancel()
	time.Sleep(50 * time.Millisecond)
}
Output:
watched: one
watched: two

Directories

Path Synopsis
examples
approvals command
Command approvals walks one weft/thread session through the approval flow v0.2 exists for (ADR 0021): a gated call parks, the process "restarts" — the session is reopened from the JSONL file — a decision arrives signed over the challenge the session minted, and the conversation resumes under it.
Command approvals walks one weft/thread session through the approval flow v0.2 exists for (ADR 0021): a gated call parks, the process "restarts" — the session is reopened from the JSONL file — a decision arrives signed over the challenge the session minted, and the conversation resumes under it.
session command
Command session walks one weft/thread session through its whole v0.1 life: two turns, a label, a branch off the first answer, a fork of the branch, a manual compaction with preview, and a reopen from disk — everything on a JSONL backend, everything offline through a scripted model, every id deterministic.
Command session walks one weft/thread session through its whole v0.1 life: two turns, a label, a branch off the first answer, a fork of the branch, a manual compaction with preview, and a reopen from disk — everything on a JSONL backend, everything offline through a scripted model, every id deterministic.
Package jsonl is the default durable thread.Storage: one directory, one <id>.jsonl file per session — the header line first, then one entry per line in append order (ADR 0011).
Package jsonl is the default durable thread.Storage: one directory, one <id>.jsonl file per session — the header line first, then one entry per line in append order (ADR 0011).
Package pool provides bounded concurrent child runs for thread sessions (ADR 0022): one process-wide semaphore with FIFO fairness, subagent tools whose children hold a slot, child sessions linked to the parent session and call, and receipts recording every delegation's journey.
Package pool provides bounded concurrent child runs for thread sessions (ADR 0022): one process-wide semaphore with FIFO fairness, subagent tools whose children hold a slot, child sessions linked to the parent session and call, and receipts recording every delegation's journey.
sqlite module
Package threadtest is the shared conformance table for thread.Storage backends — the executable form of ADR 0011 §5's promises.
Package threadtest is the shared conformance table for thread.Storage backends — the executable form of ADR 0011 §5's promises.

Jump to

Keyboard shortcuts

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