digest

package
v2.7.0 Latest Latest
Warning

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

Go to latest
Published: Jul 20, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package digest consolidates the digesting primitives core-agent uses to keep large tool responses out of the parent context. Inspired by Headroom (Netflix, Apache 2.0), which ships the same idea as a Python library.

Three primitives, each independently useful and testable:

  • Content router — sniff the payload shape and dispatch (passthrough / structural JSON / LLM fallback).
  • Structural JSON pruner — preserve identifier-shaped keys, collapse long strings and arrays, recurse with a depth cap. Deterministic, no API call.
  • CCR store — keep the raw payload locally keyed by tool-call ID so the model can fetch it back via a retrieve_raw built-in tool. (Skeleton PR: store interface + implementations land in the follow-up per docs/digest-design.md sequencing.)

LLM-agnostic: this package digests payloads. It does not import pkg/agent, does not know what an MCP tool is, does not reach for the model loop. Callers pass an LLMFallback function if they want one.

Full design: docs/digest-design.md. Tracking issue: #128.

Index

Constants

View Source
const (
	MethodPassthrough    = "passthrough"
	MethodStructuralJSON = "structural_json"
	MethodLLMFallback    = "llm_fallback"
)

Method values populated on Result.Method — the observable dispatch decision the router made. Callers surface these in telemetry (per-tool method distribution → drives the decision on whether to add tool-specific pruners).

View Source
const (
	// MaxStringChars caps individual string values. Longer strings
	// collapse to "<truncated, N chars>". Identifier-shaped values
	// (see identifierKey) are exempt — losing the tail of a URL or
	// an ID field defeats the whole purpose.
	MaxStringChars = 500

	// MaxArrayElems caps arrays. Longer arrays collapse to a summary
	// object with the first and last (MaxArrayElems/2) elements plus
	// a total + dropped count. Preserves head + tail so paginated /
	// sorted views stay legible.
	MaxArrayElems = 20

	// MaxDepth caps object recursion. Subtrees deeper than this
	// collapse to "<truncated, deep subtree>". Guards against
	// pathological nesting from adversarial inputs.
	MaxDepth = 8
)

Pruner limits. Fixed for v1 per docs/digest-design.md open question 2 ("operator-tunable, or fixed for v1? Proposal: fixed, with package-level overrides for tests"). Add flags only if telemetry shows operators need them.

View Source
const DefaultStoreMaxTotalBytes = 100 * 1024 * 1024

DefaultStoreMaxTotalBytes is the FilesystemStore default cap. 100 MiB is generous for a session's tool-call raw payloads while staying well under any reasonable tmp partition. Tune per your deployment; zero disables the bound.

View Source
const MaxPassthroughBytes = 64 * 1024

MaxPassthroughBytes bounds how much prose data is returned verbatim when neither a structural pruner nor an LLMFallback is available. Payloads over this cap are truncated with a "…<N more bytes>" suffix so a caller who forgot to wire an LLMFallback still doesn't slam the model with a megabyte of raw text.

Variables

View Source
var ErrNotFound = errors.New("digest: store entry not found")

ErrNotFound is returned by Store.Get when a callID has no entry. Callers surface this to the model via retrieve_raw as a clear "unknown call_id" error rather than a generic 500.

Functions

func PruneJSON

func PruneJSON(payload []byte) (string, map[string]any)

PruneJSON deterministically compresses a JSON payload using the rules documented in docs/digest-design.md. Returns the pruned JSON as a string plus metadata describing what happened (arrays collapsed, strings truncated, subtrees dropped).

Idempotent: PruneJSON(PruneJSON(x)) equals PruneJSON(x). The pruned output is always valid JSON — callers can hand it back into a second Process pass without any special-case wiring.

Never returns an error: payloads that fail to parse fall through as a "<invalid_json>" wrapper so the caller still gets *something* and the router's structural_json dispatch stays observable in telemetry. Callers who need "did this actually prune JSON" can inspect the returned metadata for the "parse_error" key.

func ResetTelemetry

func ResetTelemetry()

ResetTelemetry zeroes the package counters. Test-only helper — production consumers snapshot + diff rather than reset, since the counter is process-wide and other observers might be reading it.

func SetIdentifierKeyPattern

func SetIdentifierKeyPattern(re *regexp.Regexp)

SetIdentifierKeyPattern overrides the default identifier-key regex. Test-only hook — production code should not call this. Passing nil resets to the default.

Types

type EventlogStore

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

EventlogStore is a Store backed by pkg/eventlog. Persists raw payloads as dedicated session.Event records tagged with Author == "digest.raw" and the callID + payload in CustomMetadata. Get scans the session's events for a matching record and returns the decoded raw bytes.

Why a dedicated event record rather than reusing the tool-response row: the #84 wrap-layer flow substitutes the model-facing tool response with a digest before Agent.Run persists it, so the tool-response row in the eventlog carries the digest, not the raw. Recording raw as its own event keeps the audit trail complete and gives retrieve_raw a stable key to look up by, without a new database table.

Sub-session isolation (issue #273): writes land in a derived session ID "<parent>:digest", NOT the parent's own row. ADK's database session service tracks last_update_time per row and rejects appends with a "stale session error" when an out-of-band writer bumps the row while another caller is holding a stale session.Session snapshot. Since digest.Process fires synchronously from the middle of a tool call (see pkg/mcp/digest_wrap.go), a direct Put against the parent row races the runner's own storedSession — which the runner captured at the start of the turn and holds through every AppendEvent that follows. Writing to a derived row sidesteps the race entirely; the same technique pkg/agent/subagent.go uses for the sub-runner isolation documented in docs/eventlog-decisions.md.

Depends on --session-db: constructors take an *eventlog.Handle, which is nil when the operator hasn't enabled the session database. NewEventlogStore returns an error rather than falling back silently — callers who want a filesystem-only fallback wire FilesystemStore explicitly.

Safe for concurrent use — all writes go through the eventlog service's write mutex; reads use the underlying Stream which is concurrent-safe by design.

func NewEventlogStore

func NewEventlogStore(handle *eventlog.Handle, appName, userID, sessionID string) (*EventlogStore, error)

NewEventlogStore constructs an EventlogStore for the given session. Returns an error when handle is nil (no --session-db) or when any of appName/userID/sessionID is empty — a session-scoped store with missing identity would silently write into the wrong session.

func (*EventlogStore) Get

func (s *EventlogStore) Get(ctx context.Context, callID string) ([]byte, error)

Get implements Store. Scans the session's events via Stream.Since with the WithAuthor filter set to "digest.raw", returning the most recent match's decoded payload. Returns ErrNotFound when no event matches callID.

Scan cost: O(events emitted with Author=="digest.raw" in this session). retrieve_raw is model-driven and rare, so the cost is acceptable; if telemetry shows it dominates, a follow-up patch can add an in-memory callID → seq index over Stream.Watch.

func (*EventlogStore) Put

func (s *EventlogStore) Put(ctx context.Context, callID string, raw []byte) error

Put implements Store. Ensures the derived digest sub-session exists, fetches its session.Session, constructs a "digest.raw" event carrying the callID + base64-encoded payload, and appends it through the eventlog service.

Writing to the derived <sessionID>:digest row (not the parent's) is what keeps digest.Process from tripping ADK's optimistic- concurrency check against the runner's mid-turn session snapshot (issue #273). See the EventlogStore godoc for the full rationale.

Empty callID is rejected — same contract as FilesystemStore.

type FilesystemStore

type FilesystemStore struct {
	// Dir is the storage root. Must exist and be writable. Create
	// with NewFilesystemStore rather than a bare struct literal so
	// the directory is validated + created up front.
	Dir string

	// MaxTotalBytes bounds cumulative on-disk usage. When a Put
	// would push total bytes over the limit, oldest entries are
	// evicted (FIFO on insert order) until the new payload fits.
	// Zero disables the bound — useful for tests, not recommended
	// for production.
	MaxTotalBytes int64
	// contains filtered or unexported fields
}

FilesystemStore is a file-per-callID Store rooted at Dir. Defaults to a bounded LRU/FIFO eviction policy so long-running sessions don't accumulate indefinitely. Safe for concurrent use.

Storage layout: <Dir>/<sanitizedCallID>. The sanitizer prevents path traversal (../foo) and unsafe characters — callIDs are opaque from the caller's perspective, but tool-call IDs originate from the model and shouldn't be trusted verbatim.

Default Dir per feedback_uat_files_in_tmp memory: under os.TempDir(), never $HOME. Callers pin Dir explicitly for production wiring; tests and library defaults land in /tmp.

func NewFilesystemStore

func NewFilesystemStore(dir string) (*FilesystemStore, error)

NewFilesystemStore creates or opens a FilesystemStore rooted at dir. Empty dir defaults to <os.TempDir()>/core-agent-digest — the project's tmp-not-$HOME convention (per feedback_uat_files_in_tmp memory).

Existing files under dir are indexed on open so restarts don't silently exceed MaxTotalBytes; the FIFO order after re-index is deterministic (lexicographic callID) since insertion order isn't recoverable from mtime cheaply. Callers that need cross-restart LRU should use EventlogStore (follow-up PR) instead.

func (*FilesystemStore) Bytes

func (s *FilesystemStore) Bytes() int64

Bytes returns cumulative on-disk usage tracked by the store. Test helper.

func (*FilesystemStore) Get

func (s *FilesystemStore) Get(_ context.Context, callID string) ([]byte, error)

Get implements Store. Reads directly from disk without touching the FIFO order — the store is FIFO on insertion, not LRU on read.

func (*FilesystemStore) Len

func (s *FilesystemStore) Len() int

Len returns the number of entries currently indexed. Test helper; callers should not rely on it for production logic.

func (*FilesystemStore) Put

func (s *FilesystemStore) Put(_ context.Context, callID string, raw []byte) error

Put implements Store. Fails fast on empty callID (see interface doc); on a duplicate callID, evicts the prior entry's bytes from the counter before writing the new one so the running total stays honest.

type LazyStore

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

LazyStore is a Store whose delegate is set after construction. Solves a chicken-and-egg problem in the CLI wiring: the MCP wrap layer needs a Store reference at Build time, but the EventlogStore needs a session ID that isn't known until the agent constructs. LazyStore lets callers pass a stable Store reference to the wrap layer up front, then Set the real delegate after agent.New returns.

Reads before Set return ErrNotFound (safe passthrough — the digest still runs, just no retrieval). Writes before Set are dropped silently — the caller shouldn't have wired a CallID if it wasn't ready to accept writes. Reads/writes after Set delegate to the inner Store atomically.

Safe for concurrent use: Set can race Get/Put and both callers see consistent state (either pre-Set no-op or post-Set delegation, never a torn read).

func (*LazyStore) Get

func (l *LazyStore) Get(ctx context.Context, callID string) ([]byte, error)

Get implements Store. Returns ErrNotFound when no delegate is set. retrieve_raw surfaces this as a clean "no raw payload" tool response.

func (*LazyStore) Put

func (l *LazyStore) Put(ctx context.Context, callID string, raw []byte) error

Put implements Store. Drops the write when no delegate is set (see LazyStore docstring); the caller shouldn't have supplied a CallID pre-Set, so this is a no-op safety net rather than an error path.

func (*LazyStore) Set

func (l *LazyStore) Set(delegate Store)

Set installs delegate as the LazyStore's backing. Subsequent Put/Get calls proxy to delegate. Safe to call multiple times (each Set overrides the last), though typical wiring calls Set exactly once after agent construction. Pass nil to detach (subsequent calls degrade to no-op / ErrNotFound).

type Options

type Options struct {
	// Threshold: payloads smaller than this bypass digesting entirely.
	// Zero = 0 bytes = always digest; callers typically want a
	// meaningful value (e.g. 4096) so tiny responses skip the router
	// overhead.
	Threshold int

	// Store: optional CCR backing. When non-nil AND CallID is
	// non-empty, Process writes the raw payload to the store before
	// returning and populates Result.CallID so the caller can weave
	// the ID into the synthetic map handed to the model. When nil or
	// CallID is empty, no retrieval is possible and CallID stays
	// empty on the way back.
	//
	// Store errors are surfaced in Result.Metadata["store_err"] but
	// don't fail Process — losing retrieval capability shouldn't
	// break the primary digest path.
	Store Store

	// LLMFallback: optional prose digester. Called when the router
	// cannot dispatch to a structural pruner. When nil, payloads that
	// would fall through return Method == passthrough with Digest
	// truncated to a safe upper bound (see MaxPassthroughBytes) so we
	// never silently dump megabytes into the model's context.
	LLMFallback func(ctx context.Context, raw []byte) (string, error)

	// CallID: caller-provided identifier (e.g. tool-call ID). When
	// empty, Process leaves Result.CallID empty and skips the Store
	// write even when Store is non-nil.
	CallID string
}

Options configure a single Process call. All fields are optional; a zero Options passes payloads through verbatim (which is useful for telemetry-only wiring where the caller wants byte counts but not compression).

type Result

type Result struct {
	Digest   string         // compressed payload (caller hands this to the model)
	Method   string         // one of the Method* constants above
	RawBytes int            // serialized size of the original
	CallID   string         // opaque ID for CCR retrieval (empty until Store lands)
	Metadata map[string]any // pruner-specific stats (e.g. {"arrays_collapsed": 3})
	Savings  *Savings       // per-call byte + token reduction; nil only on nil-ctx error
}

Result is what Process returns to the caller. RawBytes is the serialized size of the original payload — useful for telemetry even when Method is passthrough. CallID is populated when a Store is wired (follow-up PR); the skeleton always leaves it empty.

Savings is populated on every success path (including passthrough, where OriginalBytes ≈ DigestBytes so savings are ~0) and gives callers the per-call byte + token math they need to surface operator-visible savings totals without recomputing from Digest / RawBytes themselves. See docs/agentic-mcp-design.md § "savings telemetry" for the full display + OTel wiring.

func Process

func Process(ctx context.Context, payload []byte, opts Options) (Result, error)

Process digests payload according to opts. It never returns an error for content-shape reasons — pruner failures fall through to the LLM fallback or passthrough. The only error path is a caller mistake (nil ctx) or an LLMFallback that errors out; even the latter degrades to a truncated-passthrough Result so the caller still has *something* to hand to the model.

When opts.Store is wired AND opts.CallID is set, the raw payload is persisted to the store before the dispatch decision is made (so retrieve_raw works even when the router chose passthrough). Store failures degrade to a Result with Metadata["store_err"] set — losing retrieval capability shouldn't break the primary digest path.

type Savings

type Savings struct {
	// Path mirrors Result.Method — denormalized for callers that
	// only carry Savings around (e.g. an eventlog metadata blob).
	Path string

	// Byte counts of the payload before and after digesting.
	// Deterministic; measured on serialized JSON (or raw prose for
	// passthrough).
	OriginalBytes int
	DigestBytes   int

	// Token estimates. See package docs on the 4-char-per-token
	// heuristic and its accuracy bounds.
	OriginalTokensEst int
	DigestTokensEst   int

	// Agentic path only. Zero on structural / passthrough. Populated
	// by the caller after invoking the small-tier subagent, from
	// the subagent's ResponseUsage.
	SubagentModel        string
	SubagentInputTokens  int
	SubagentOutputTokens int
}

Savings quantifies the byte + token reduction one Process call achieved, plus (agentic path only) the offsetting subagent LLM cost. Populated on every Result Process returns; the pointer wrap keeps a nil marker available for the (only) failure path (nil ctx) where nothing was measured.

The 4-char-per-token estimate for Original/DigestTokensEst is a cheap heuristic — no tokenizer round-trip. Accurate to ±15% for typical mixed content (JSON / prose / code). Suitable for savings display; not suitable for billing enforcement.

SubagentModel / SubagentInputTokens / SubagentOutputTokens are left ZERO by pkg/digest — the package doesn't own the subagent LLM (that lives in the caller, e.g. the MCP agentic wrapper). Callers populate these AFTER Process returns from the subagent's ResponseUsage, then hand the Result off to whatever surfaces the telemetry (eventlog, /stats, OTel span attributes).

Dollar-cost figures are NOT stored here. They're computed at display time via usage.Tracker's layered pricing chain so historical digests re-price correctly when rates change and so pkg/digest stays free of the pricing dependency graph.

type Store

type Store interface {
	// Put records raw under callID, overwriting any prior entry.
	// Callers that pass an empty callID get a synthetic error — the
	// key is what makes retrieval possible; storing anonymously is
	// a bug at the call site.
	Put(ctx context.Context, callID string, raw []byte) error

	// Get returns the raw payload previously Put under callID, or
	// ErrNotFound when the key is unknown or was evicted.
	Get(ctx context.Context, callID string) ([]byte, error)
}

Store is the CCR backing for raw payloads. The retrieve_raw built-in tool (follow-up PR) is the model-facing consumer; Process is the writer. Implementations must be safe for concurrent use.

type TelemetrySnapshot

type TelemetrySnapshot struct {
	MethodCounts map[string]int64
	BytesSaved   map[string]int64
}

TelemetrySnapshot is a point-in-time view of the digest telemetry counters. Copy-by-value so consumers can read without holding the package mutex.

MethodCounts is calls-per-method (MethodPassthrough / MethodStructuralJSON / MethodLLMFallback). BytesSaved is the cumulative reduction — for each call, (raw_bytes - digest_bytes) accrued to the call's method. Passthrough always contributes 0 (raw == digest by definition); operators reading the surface should treat the delta between the two structural fields as the compression win.

func Telemetry

func Telemetry() TelemetrySnapshot

Telemetry returns a defensive copy of the current counter state. Safe for concurrent readers; callers get an isolated snapshot they can walk without racing writers.

Jump to

Keyboard shortcuts

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