Documentation
¶
Overview ¶
Package history stores conversation turns so a follow-up message in the same A2A context is answered with the prior exchange in the prompt. The store keeps only what conversation memory needs — the user's text and the assistant's final answer per turn — never tool transcripts, which can be arbitrarily large and are re-derivable by calling the tool again.
Keying is (projectName, contextID), not contextID alone: the project is the authorization boundary, so a caller who guesses another project's contextID must not inherit its history.
Index ¶
- Constants
- Variables
- func EstimateTokens(turns []Turn) int
- func IsSummaryTurn(t Turn) bool
- func Messages(turns []Turn) []agentcore.Message
- func NormalizeName(name string) string
- func TitleOf(opening string) string
- type Conversation
- type Lister
- type MemoryStore
- func (s *MemoryStore) Append(_ context.Context, projectName, contextID string, turn Turn) error
- func (s *MemoryStore) Compact(_ context.Context, projectName, contextID string, summary Turn, keep []Turn) error
- func (s *MemoryStore) GetConversation(_ context.Context, projectName, contextID string) (Conversation, error)
- func (s *MemoryStore) ListConversations(_ context.Context, projectName string, limit int) ([]Conversation, error)
- func (s *MemoryStore) Messages(_ context.Context, projectName, contextID string) ([]Message, error)
- func (s *MemoryStore) Rename(_ context.Context, projectName, contextID, name string) error
- func (s *MemoryStore) Turns(_ context.Context, projectName, contextID string) ([]Turn, error)
- type Message
- type PostgresStore
- func (s *PostgresStore) Append(ctx context.Context, projectName, contextID string, turn Turn) error
- func (s *PostgresStore) Close()
- func (s *PostgresStore) Compact(ctx context.Context, projectName, contextID string, summary Turn, keep []Turn) error
- func (s *PostgresStore) GetConversation(ctx context.Context, projectName, contextID string) (Conversation, error)
- func (s *PostgresStore) ListConversations(ctx context.Context, projectName string, limit int) ([]Conversation, error)
- func (s *PostgresStore) Messages(ctx context.Context, projectName, contextID string) ([]Message, error)
- func (s *PostgresStore) Ping(ctx context.Context) error
- func (s *PostgresStore) Rename(ctx context.Context, projectName, contextID, name string) error
- func (s *PostgresStore) Turns(ctx context.Context, projectName, contextID string) ([]Turn, error)
- type Reader
- type Renamer
- type Store
- type Turn
Constants ¶
const DefaultMaxRecentTurns = 200
DefaultMaxRecentTurns bounds how many of a conversation's newest turns the Postgres store fetches for replay. It exists so the replay query is O(bounded) regardless of conversation length — the agent layer's token budget then truncates further. 200 turns comfortably exceeds any sane token budget (6000 estimated tokens ≈ 24k chars ≈ a few dozen turns).
const MaxNameLen = 80
MaxNameLen caps Conversation.Name — the name a user gives a conversation with /rename — in runes. Shorter than MaxTitleLen because a name is meant to be scanned in a list, not read: it competes with the derived title for the same column.
const MaxStoredContentLen = 32 * 1024
MaxStoredContentLen caps how many bytes of a single user/assistant message any store persists. Conversation memory truncates by token budget on replay (see Truncate), so this is not a functional limit — it is a storage guard so one pathological message (a pasted file, a runaway generation) cannot balloon a row or an in-memory slice without bound. Both stores enforce it identically so a conversation reads back the same whether durable or in-process.
const MaxSummaryTurnLen = 4000
MaxSummaryTurnLen caps how many bytes of digest a summary turn's AssistantText holds. A summarization pass exists precisely to make stored history smaller, so its output must be far more compact than MaxStoredContentLen (the general per-message guard) — mirrors [gapreport.MaxSummaryLen]'s posture of a small fixed cap, sized up from gapreport's because a conversation digest needs more room than a one-line capability-gap summary.
const MaxTitleLen = 120
MaxTitleLen caps Conversation.Title in runes: long enough to read what a conversation was about in a list, short enough that a listing of a hundred conversations stays a small payload.
const MaxTurnsPerConversation = 1000
MaxTurnsPerConversation caps how many turns any one conversation retains. Replay only ever reads the newest DefaultMaxRecentTurns (200), so a cap well above that keeps far more than memory needs while bounding growth: the Postgres store deletes older message pairs at append time, and the memory store drops older turns. Fleet-level age-based retention is a follow-up.
Variables ¶
var ErrConversationNotFound = errors.New("conversation not found")
ErrConversationNotFound is returned by Reader.GetConversation when no conversation exists for the (project, context) key. Callers (the apiserver read view) map it to a 404.
Functions ¶
func EstimateTokens ¶
EstimateTokens sums [estimateTokens]'s heuristic across turns. Exposed so callers that need to reason about replay cost before it's time to actually call Truncate (the summarization compaction trigger, in particular) share the exact same heuristic Truncate uses rather than reimplementing it.
func IsSummaryTurn ¶
IsSummaryTurn reports whether t is a compaction digest (produced by conversation summarization) rather than an ordinary user/assistant exchange. AssistantText holds the digest itself.
func NormalizeName ¶
NormalizeName folds a user-supplied conversation name into what the stores keep: whitespace (including newlines) collapsed to single spaces and the result cut to MaxNameLen runes. Empty — or all whitespace — means "no name", which clears one that was set.
func TitleOf ¶
TitleOf folds an opening user message into a Conversation.Title: internal whitespace (including newlines) collapses to single spaces and the result is cut to MaxTitleLen runes with an ellipsis. Empty in, empty out.
Types ¶
type Conversation ¶
type Conversation struct {
ProjectName string
ContextID string
CreatedAt time.Time
LastActiveAt time.Time
TurnCount int64
// Title is what the conversation is about: the opening user message,
// collapsed to one line and cut to MaxTitleLen runes. Empty when the
// conversation has no ordinary user turn (only a compaction summary).
Title string
// Name is what the user called this conversation (see [Renamer]), empty
// until they name one. It is kept alongside Title rather than replacing
// it so a rename never destroys the derived label underneath — clients
// show the name when set and fall back to the title.
Name string
}
Conversation is the stored metadata of one conversation, for per-project listing (newest activity first).
type Lister ¶
type Lister interface {
ListConversations(ctx context.Context, projectName string, limit int) ([]Conversation, error)
}
Lister lists a project's conversations. It is a separate interface from Store because the agent loop never needs it — it exists for consumers (a conversation-list API) and for operational inspection.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is an in-process Store and Lister. History lives for the lifetime of the service process; PostgresStore is the durable equivalent behind the same interfaces.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an empty in-memory store.
func (*MemoryStore) Append ¶
Append implements Store. Over-long turn text is truncated to MaxStoredContentLen, and a conversation retains at most MaxTurnsPerConversation turns (oldest dropped) so a long-lived process cannot grow without bound.
func (*MemoryStore) Compact ¶
func (s *MemoryStore) Compact(_ context.Context, projectName, contextID string, summary Turn, keep []Turn) error
Compact implements Store.Compact: replaces the conversation's turn slice under the same mutex Append uses, and touches lastActiveAt the same way Append does — compaction is conversation activity, not a background sweep.
func (*MemoryStore) GetConversation ¶
func (s *MemoryStore) GetConversation(_ context.Context, projectName, contextID string) (Conversation, error)
GetConversation implements Reader.
func (*MemoryStore) ListConversations ¶
func (s *MemoryStore) ListConversations(_ context.Context, projectName string, limit int) ([]Conversation, error)
ListConversations implements Lister: the project's conversations, newest activity first. limit <= 0 uses 100.
func (*MemoryStore) Messages ¶
Messages implements Reader. Each ordinary stored turn expands to a user message and an assistant message; a summary turn (see IsSummaryTurn) renders as a single message with Role "summary" instead — its UserText is the internal compaction marker, not something a human said, so pairing it with a synthetic user row would misrepresent it as part of the transcript. seq is assigned per emitted message (1 for a summary turn, 2 for an ordinary turn), so it stays a dense, monotonically increasing 1-based index regardless of how many summary turns a conversation has accumulated; createdAt is the conversation's last-active time since the memory store keeps no per-message timestamp (the durable store does).
func (*MemoryStore) Rename ¶
func (s *MemoryStore) Rename(_ context.Context, projectName, contextID, name string) error
Rename implements Renamer. It deliberately leaves lastActiveAt alone: naming a conversation says something about it, it is not a turn in it, and reordering the picker under the user's cursor would be a surprise.
type Message ¶
Message is a single stored message row, exposed to the read view (apiserver messages subresource). seq is the absolute 1-based message index within the conversation.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
PostgresStore is a durable Store on PostgreSQL. Safe for concurrent use; concurrent appends to the same conversation serialize on the conversation row. Construct with NewPostgresStore, release with Close.
func NewPostgresStore ¶
func NewPostgresStore(ctx context.Context, databaseURL string, logger *slog.Logger) (*PostgresStore, error)
NewPostgresStore connects to databaseURL (a postgres:// URL), verifies the connection, and applies the schema. It fails fast on an unreachable or unwilling database — a service configured for durable history must not silently fall back to amnesia.
func (*PostgresStore) Append ¶
Append implements Store: one transaction that bumps the conversation's turn count (creating it if new) and inserts the turn's two message rows. The RETURNING-ed turn count assigns seq, so concurrent appenders serialize on the conversation row and never collide. Over-long turn text is truncated to MaxStoredContentLen and the conversation is pruned to maxTurns, both in the same transaction, so a single conversation cannot grow without bound.
func (*PostgresStore) Compact ¶
func (s *PostgresStore) Compact(ctx context.Context, projectName, contextID string, summary Turn, keep []Turn) error
Compact implements Store.Compact: one transaction that deletes every existing message row for the conversation and re-inserts summary followed by keep as fresh, sequential (project_name, context_id, seq) pairs starting at 1 again. Old seq values cannot simply be reused once the rows between them are gone, so this renumbers from scratch rather than trying to splice into the existing sequence — the conversation's turn_count is reset to match (1 + len(keep)) in the same transaction so later Appends continue the new, shorter sequence correctly.
func (*PostgresStore) GetConversation ¶
func (s *PostgresStore) GetConversation(ctx context.Context, projectName, contextID string) (Conversation, error)
GetConversation implements Reader: one conversation's metadata by (project, context) key, or ErrConversationNotFound if absent.
func (*PostgresStore) ListConversations ¶
func (s *PostgresStore) ListConversations(ctx context.Context, projectName string, limit int) ([]Conversation, error)
ListConversations implements Lister: the project's conversations, newest activity first. limit <= 0 uses 100.
func (*PostgresStore) Messages ¶
func (s *PostgresStore) Messages(ctx context.Context, projectName, contextID string) ([]Message, error)
Messages implements Reader: a conversation's message rows, oldest first. The underlying table always stores a summary turn as an ordinary ('user','assistant') row pair — the CHECK(role IN ('user','assistant')) constraint on the messages table admits nothing else, and Compact never needed to change that shape (see the design doc's read-path note): the summary marker lives in the row content, the same signal IsSummaryTurn uses in memory. So this pairs adjacent rows by seq (turn k is rows 2k-1/2k, same arithmetic PostgresStore.Turns uses) purely to recognize that marker, then renders a recognized pair as a single Role:"summary" message (dropping the synthetic user row) and everything else as the verbatim user/assistant rows it always was. Output seq is renumbered densely from 1 as messages are emitted, matching MemoryStore.Messages.
func (*PostgresStore) Ping ¶
func (s *PostgresStore) Ping(ctx context.Context) error
Ping verifies the database connection is alive. Used by the conversations apiserver's readyz check.
func (*PostgresStore) Rename ¶
func (s *PostgresStore) Rename(ctx context.Context, projectName, contextID, name string) error
Rename implements Renamer: a single UPDATE of the conversation row, which must already exist (0 rows affected is ErrConversationNotFound — a rename never conjures a conversation the way Append's upsert does). A name that normalizes to empty is stored as NULL, so "never named" and "named, then cleared" read back identically. last_active_at is deliberately untouched: naming a conversation is not activity in it, and bumping it would reshuffle the newest-first listing under the user's cursor.
type Reader ¶
type Reader interface {
Lister
// GetConversation returns one conversation's metadata, or
// [ErrConversationNotFound] if the (project, context) key is unknown.
GetConversation(ctx context.Context, projectName, contextID string) (Conversation, error)
// Messages returns a conversation's messages, oldest first (ascending seq).
// An unknown conversation yields nil, nil.
Messages(ctx context.Context, projectName, contextID string) ([]Message, error)
}
Reader is the read-only view over stored conversations, consumed by the conversations apiserver. It is separate from Store (the chat hot path) and Lister so the apiserver depends only on what it needs. Both MemoryStore and PostgresStore implement it.
type Renamer ¶
type Renamer interface {
// Rename sets the conversation's name (see [Conversation.Name]), or
// clears it when name normalizes to empty. An unknown (project, context)
// key yields [ErrConversationNotFound] — a rename creates nothing.
Rename(ctx context.Context, projectName, contextID, name string) error
}
Renamer sets a conversation's user-given name. It is spelled as its own interface, the way Lister and Reader are, so the HTTP layer's rename endpoint can depend on this one method instead of the whole chat-path Store.
type Store ¶
type Store interface {
// Turns returns the conversation's turns, oldest first. A conversation
// that has never been seen yields nil, nil.
Turns(ctx context.Context, projectName, contextID string) ([]Turn, error)
// Append records one completed turn at the end of the conversation.
Append(ctx context.Context, projectName, contextID string, turn Turn) error
// Compact atomically replaces every stored turn with summary followed by
// keep, preserving keep's order. Used only by the summarization
// compaction step (internal/agent); never called from the normal chat
// Append path.
Compact(ctx context.Context, projectName, contextID string, summary Turn, keep []Turn) error
Renamer
}
Store persists and recalls a conversation's turns. Implementations must be safe for concurrent use.
type Turn ¶
Turn is one completed exchange: what the user said and what the assistant answered.
func NewSummaryTurn ¶
NewSummaryTurn returns a summary turn holding digest, truncated to MaxSummaryTurnLen (backing off to a UTF-8 rune boundary) so a pathological digest cannot itself become the next budget problem. Callers should use this rather than constructing a Turn directly so every summary turn is recognized identically by IsSummaryTurn.