Skip to content

Add ConversationResource for agent memory and conversation state #511

@kriszyp

Description

@kriszyp

Context

We would like to broadly support agentic data like working memory, conversation history, derived facts, embeddings of past interactions, durable task state, audit trails of decisions and actions" as native runtime properties.

Today these are buildable on tables but there is no opinionated primitive. Every team writing an agent on Harper would invent their own conversation schema, compaction policy, and recall logic. This issue proposes ConversationResource as the standard primitive, used by:

Data model (three auto-created tables)

// conversations
{
  id: ULID                          // @primaryKey
  tenant: string
  owner: string                     // user or agent id
  title: string?                    // optional, human or LLM-assigned
  participants: string[]            // actor ids
  metadata: JSON                    // app-specific
  created: timestamp
  updated: timestamp
  summarized_through: turnId?       // last turn included in latest summary
  ttl: number?                      // seconds; hard delete on expiry
}

// turns
{
  id: ULID                          // @primaryKey
  conversation:  conversations.id  // @indexed
  actor: string                     // user id, agent id, tool id
  role: 'user' | 'assistant' | 'system' | 'tool'
  content: string | StructuredContent
  tool_calls: ToolCall[]?
  tool_results: ToolResult[]?
  tokens: { prompt, completion }?
  model: string?
  adapter: string?
  embedding: Vector                 // @embed(source: "content", model: "default")
  metadata: JSON
  created: timestamp
  superseded_by: turnId?            // for edits/regenerations
}

// conversation_facts (optional, opt-in per conversation)
{
  id: ULID                          // @primaryKey
  conversation:  conversations.id  // @indexed
  subject: string
  predicate: string
  object: string
  source_turn:  turns.id
  confidence: number
  embedding: Vector                 // @embed(source: "object", model: "default")
  created: timestamp
}

API

class ConversationResource extends Resource {
  async append(turn: TurnInput): Promise<Turn>;
  async history(opts?: { limit?, before?, includeSummaries? }): Promise<Turn[]>;
  async recall(query: string, opts?: { k?, scope? }): AsyncIterable<Turn | Fact>;
  async summarize(through?: turnId): Promise<Summary>;
  async extractFacts(through?: turnId): Promise<Fact[]>;
  async compact(policy?: CompactionPolicy): Promise<void>;

  // Canonical primitive — assembles a model-ready message list
  async buildContext(opts: {
    tokenBudget: number;
    recentTurns?: number;            // verbatim recent turns
    semanticRecall?: { k, threshold };
    includeFacts?: boolean;
    filterParticipants?: string[];
  }): Promise<Message[]>;
}

buildContext() is what scope.models.generate() calls when conversationId is set. It:

  1. Pulls the last N turns verbatim.
  2. Includes the rolling summary for older turns.
  3. Optionally injects semantically-recalled turns and facts via vector search.
  4. Stays under tokenBudget.

Compaction

Default policy: when conversation exceeds 50 turns OR 8K cumulative content tokens, summarize the oldest unsummarized half, advance summarized_through. Old turns stay in the table for audit; buildContext() uses the summary instead.

Configurable per conversation:

metadata.compaction = {
  triggerTurns: 50,
  triggerTokens: 8000,
  summarizerModel: 'fast',
  strategy: 'rolling' | 'hierarchical' | 'never'
}

Multi-actor / multi-agent

Multiple agents and humans append to the same conversation; each turn carries actor + role. buildContext() can filter by participant for per-agent views of a shared transcript. Agent-to-agent coordination = two agents writing to a shared conversation; live updates flow through the existing subscription mechanism on the conversation id — no new infrastructure.

Integration points

  • model-access API (Add unified model-access API (scope.models) #510): GenerateOpts.conversationId → auto-append + buildContext().
  • @export: ConversationResource is auto-exposed via REST and MCP.
  • MCP (HarperFast/mcp-server): conversations are MCP resources; turns are MCP messages; recall() is an MCP tool.
  • Audit: every turn is in auditStore via the standard transaction log — audit trails come free.
  • Replication: conversations replicate across Fabric like any table; works for edge/disconnected scenarios.
  • Subscriptions: transactionBroadcast on conversations.id gives live-updating UIs for free.

Privacy and data minimization

  • Tenant boundary enforced by existing RBAC on the table.
  • ttl triggers hard delete via a sweeper job.
  • extractFacts() can run with a redaction-prompt step to strip PII before persisting.
  • Per-conversation metadata.retention = 'audit-only' | 'full' | 'ephemeral' controls whether turn content is retained or only hashes after summarization.

Streaming-append protocol

For generateStream:

  1. appendOpen() creates a pending turn row with empty content.
  2. Each chunk updates the row's content (single update transaction batched with a debounce, default 250ms — subscribers see live updates).
  3. appendCommit() finalizes status, tokens, model.
  4. Abort → superseded_by is set, row remains for audit.

Dependencies

Related work

Open decisions

  1. Vector index on turn embeddings: global with conversation-id filter, or per-conversation? Suggested: global with filter — simpler and faster to start.
  2. Fact extraction: opt-in cost (default off)? Suggested: yes — off by default, enabled per conversation in metadata.
  3. Edits/regenerations: in-place mutation or superseded_by chain? Suggested: superseded_by chain — preserves audit, mirrors git-style history.
  4. Cross-conversation memory: do we need a global "user memory" layer? Suggested: defer — let apps query across conversations via the standard vector index for v1.

Acceptance

  • ConversationResource base class lands in core alongside Resource.
  • Three system tables (conversations, turns, conversation_facts) auto-create.
  • append, history, recall, summarize, compact, buildContext implemented.
  • Streaming-append protocol works (open/chunk/commit + subscription visibility).
  • Default compaction policy triggers at 50 turns / 8K tokens.
  • Auto-exposed via REST and MCP through existing @export mechanism.
  • GenerateOpts.conversationId binding works end-to-end with model-access API (Add unified model-access API (scope.models) #510).
  • Replication and audit verified — turns appear in auditStore and replicate to followers.
  • Documented: building a chat app, building a multi-agent shared conversation, retention/TTL policy choices.

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions