noaadapter

package
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: AGPL-3.0, Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package noaadapter wires the pure noa engine into Norma: it projects llm.Message into noa's flat representation and back, persists state and archives, and exposes the Compress tool.

See docs/noa-实施方案.md for the specification.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoArchiveDir = errors.New("noaadapter: ArchiveBaseDir is required — compression must not run without a durable place for the originals")

ErrNoArchiveDir is returned when ArchiveBaseDir is empty.

Functions

func AppendRefTag

func AppendRefTag(body, tag string) string

AppendRefTag puts the tag at the END of a body.

Trailing rather than leading: a leading tag would corrupt a tool_use's JSON arguments, and models are markedly more likely to echo something that opens a message than something that closes it.

func DeriveChildState

func DeriveChildState(parent noa.CompressionState, childSessionID, childArchiveRoot string) noa.CompressionState

DeriveChildState hands a subagent the parent's compression ledger.

What carries over and what does not follows from what each field means:

  • Blocks, MessageRefs, TokenSnapshot, NextBlockID are the ledger. Without them a child that sees inherited context could not address it, and would allocate block ids that collide with the parent's.
  • Nudge is reset. Pacing describes one conversation's pressure history; the child starts its own, and inheriting a mid-session cadence would either silence it or make it nag immediately.
  • Stats is reset so the child reports its own work.

Inherited blocks keep their original ArchivePath. It is absolute and the file is still there, so the child can follow a compression back to its originals exactly as the parent could — while its own new archives land in its own directory.

func DeriveMessageID

func DeriveMessageID(role noa.Role, ct noa.ContentType, text, toolCallID, toolName string) string

DeriveMessageID computes a message's stable identity.

llm.Message has no id field, so noa derives one from the content. A content hash rather than an array index is not a stylistic choice: indices shift whenever a message is pruned, a nudge is injected, or a session resumes, and every ref in the map would break at once. A hash stays put.

text must already have any ref tag stripped. A tag inside the hash would make the id change every time the tag is re-rendered, which is every turn.

func Enable

func Enable(opts *agentcore.Options, o Options) error

Enable wires noa into a set of agentcore Options.

This is the ONLY entry point, and it is all-or-nothing by design. It attaches three things at once:

  1. opts.Compactor — takes over the view (and disables the built-in Compaction, which a session cannot run alongside it)
  2. opts.Tools += Compress — the compression tool
  3. opts.AppendSystemPrompt — the three resident prompt sections

They must live or die together. Wiring them separately makes three ways to get it half-right, and each failure is quiet:

  • no Compactor: the model compresses, the panel says it worked, and the view never changes — the compression went nowhere.
  • no tool: the prompt teaches a tool that does not exist.
  • no prompt: the model has the tool but not the rules, and writes summaries that drop paths, decisions and error strings. Nothing reports an error; it surfaces hours later as a model that has forgotten what it did.

Not calling Enable is the off switch: nothing is attached, and the built-in compaction keeps working exactly as before.

func InheritState

func InheritState(o InheritOptions) (noa.CompressionState, string, bool)

InheritState finds a usable ledger for a session that has none of its own.

A session can arrive empty for ordinary reasons — a fork, a subagent, a transcript copied without its sidecar — and starting from zero would make the model re-compress everything the ancestor already compressed. So the chain is walked until a state with blocks turns up.

Returns ok=false when nothing was found, which is the normal case for a genuinely new session.

func Materialize

func Materialize(msgs []llm.Message, o Options) ([]llm.Message, error)

Materialize applies a session's compression state to a message array so the session can continue with noa switched off.

Why this is needed: l.messages is an in-memory working copy rebuilt from the transcript on every resume, and the transcript holds the FULL history. With noa off, View no longer runs, so llm.MessagesForAPI would send all of it and overflow immediately.

It is a pure function — no transcript write, no state mutation, no trace — which buys three properties:

  • Idempotent: same transcript + same state, same output, every resume.
  • Reversible: turning noa back on costs nothing, because the block ledger was never touched.
  • The transcript stays exactly what it is: a record of what happened, with no derived content mixed in for later readers to have to distinguish.

The archive pointers inside the summaries keep working: they are plain paths to files that are still on disk, readable with the ordinary Read tool. Being able to follow a compression back to its originals does not depend on any noa code still running.

No state and no active blocks means nothing to do, so calling this unconditionally is safe.

func MessageRef

func MessageRef(m llm.Message) string

MessageRef reads a ref back out of a message's text, if it carries one.

func NewFileArchiver

func NewFileArchiver(root string) noa.Archiver

NewFileArchiver returns an Archiver rooted at dir.

func NewReuseArchiver

func NewReuseArchiver(root string) noa.Archiver

NewReuseArchiver returns an Archiver that adopts archives already on disk.

func ParseOverflowWindow

func ParseOverflowWindow(msg string) int

ParseOverflowWindow extracts a context window size from an error message.

func Reassemble

func Reassemble(cores []noa.CoreMessage, originals []llm.Message, sc *Sidecar, opts ReassembleOptions) []llm.Message

Reassemble rebuilds host messages from a projected, pruned view.

The output is for one request only. It is never written back to the host's history — message identity is a content hash, and writing a tagged or truncated body back would change every id derived from it.

func RefTag

func RefTag(ref string, m noa.CoreMessage, tokens int) string

RefTag renders the tag for one message.

tokens is a pure function of the body AT FIRST RENDER, frozen thereafter in the token snapshot. A tag whose number moves is a byte that moves, and a byte that moves invalidates the whole prefix cache from that point on — so the number describes what the model first saw, not what is there now.

func StripRefTag

func StripRefTag(s string) string

StripRefTag removes the ref tag from the end of a body.

Projection calls this before hashing: a tag inside the identity would make the id drift every turn the tag is re-rendered.

func SubagentArchiveRoot

func SubagentArchiveRoot(base, sessionID, agentID string) string

SubagentArchiveRoot is where a subagent's own archives live.

Types

type ClusterCounter

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

ClusterCounter disambiguates messages that hash identically.

Repeating a command produces byte-identical output, and two messages sharing an id would collapse into one in the ref map. The counter appends an occurrence suffix so each keeps its own identity, while the first occurrence keeps the bare hash — so an unchanged history projects to unchanged ids.

func NewClusterCounter

func NewClusterCounter() *ClusterCounter

NewClusterCounter returns a counter for one projection pass. It must be fresh per pass: reusing one across passes would give the same message a different suffix each time.

func (*ClusterCounter) Next

func (c *ClusterCounter) Next(base string) string

Next returns the id to use for the next occurrence of base.

type Compactor

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

Compactor adapts a Session to the harness's context-manager interfaces.

It satisfies harness.Compactor and harness.ContextView. Implementing the latter is what takes over request construction from llm.MessagesForAPI.

func (*Compactor) IsOverflow

func (c *Compactor) IsOverflow(err error) bool

IsOverflow reports whether err is a context-overflow rejection. The built-in compaction already recognises every provider's phrasing, so the detection is borrowed rather than duplicated.

This is also the only place that sees the error text, so it is where the provider's real window is mined from it. Reactive() runs after IsOverflow in the harness recovery path, so the learned number is already available when the mechanical valve is armed — otherwise the next view would be rebuilt to the configured limit the provider has just rejected.

func (*Compactor) Pre

func (c *Compactor) Pre(_ context.Context, msgs []llm.Message, lastInputTokens int) []llm.Message

Pre is a no-op on the history.

The built-in compaction rewrites messages here; noa must not. Message identity is a content hash over the stored text, and a rewrite would change every id derived from it, invalidating every ref the model holds.

func (*Compactor) Reactive

func (c *Compactor) Reactive(ctx context.Context, msgs []llm.Message) ([]llm.Message, bool)

Reactive recovers from a prompt-too-long error.

noa cannot ask the model to compress here — the model is not in the loop at this point. What it can do is arm the mechanical valve and VERIFY the result: returning true without actually shrinking the view would produce a 413 → retry → 413 loop, which is worse than a clean failure. So the view is rebuilt and measured, and false is returned if it did not shrink.

func (*Compactor) View

func (c *Compactor) View(_ context.Context, msgs []llm.Message) []llm.Message

View builds the request's message array.

type DeadRangeTracker

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

DeadRangeTracker breaks the loop where a model keeps asking to compress refs that cannot resolve.

A small model handed a stale ref will often resubmit the identical batch: the rejection tells it the range is dead, but not that repeating it is futile. Each retry costs a full turn, and the failure ladder alone is slow to bite because a parse-clean call that reclaims nothing still looks like progress.

So the second sighting of the same range set is refused outright, with the live ranges named — a rejection the model can act on instead of repeat.

func NewDeadRangeTracker

func NewDeadRangeTracker() *DeadRangeTracker

NewDeadRangeTracker returns an empty tracker.

func (*DeadRangeTracker) Check

func (d *DeadRangeTracker) Check(ranges []noa.CompressRange, state noa.CompressionState) string

Check reports whether this batch has already failed often enough to refuse. The returned string is the refusal to show the model; "" means proceed.

func (*DeadRangeTracker) Record

func (d *DeadRangeTracker) Record(ranges []noa.CompressRange)

Record notes that a batch failed.

func (*DeadRangeTracker) Reset

func (d *DeadRangeTracker) Reset()

Reset clears the tracker after a successful compression: the view changed, so what was dead before may not be now.

type InheritOptions

type InheritOptions struct {
	// ArchiveBaseDir is shared by every session in the chain.
	ArchiveBaseDir string
	// SessionID is the session being opened.
	SessionID string
	// ParentIDs are candidate ancestors, nearest first. The walk stops at the
	// first one with blocks.
	ParentIDs []string
}

InheritOptions describes one link in a parent chain.

type Options

type Options struct {
	// ArchiveBaseDir is where archives and state live. REQUIRED: noa refuses to
	// guess. The archive is the only route back to compressed originals, so
	// where it lands must be the host's explicit decision — never a silent
	// fallback to a temp directory that may be swept away.
	ArchiveBaseDir string
	// SessionID names the subdirectory under ArchiveBaseDir.
	SessionID string
	// Config overrides the defaults; the zero value means DefaultConfig.
	Config *noa.Config
	// ModelContextLimit sizes the default config when Config is nil.
	ModelContextLimit int
	// ParentSessionIDs are ancestors to inherit a ledger from when this session
	// has none of its own, nearest first. A fork, a subagent, or a transcript
	// copied without its sidecar would otherwise start from zero and re-compress
	// everything the ancestor already did. At most maxInheritDepth are tried.
	ParentSessionIDs []string
	// OnWarn receives non-fatal diagnostics. noa writes nothing to stdout or
	// stderr on its own.
	OnWarn func(string)
	// Now overrides the clock, for tests.
	Now func() time.Time
}

Options configures one noa-managed session.

type ReassembleOptions

type ReassembleOptions struct {
	// State supplies the ref map and token snapshot for tagging.
	State *noa.CompressionState
	// Tag enables ref tags. Materialize turns them off: they point at an
	// addressing scheme that is no longer running.
	Tag bool
}

ReassembleOptions controls how cores are rebuilt into host messages.

type Session

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

Session is the state the compactor and the Compress tool share.

They are two entry points into one system and must see the same data:

  • state: the tool creates blocks, the view hides them. If they diverge, the model compresses into a void — the panel reports success and the next request still carries the full history.
  • lastView: the refs the model used were resolved against the array the view produced. Re-deriving it in the tool could yield a different array and therefore a misaligned range.
  • attempts: the tool counts failures, the view reads the count to decide whether to keep nudging.

func EnableWithSession

func EnableWithSession(opts *agentcore.Options, o Options) (*Session, error)

EnableWithSession is Enable, also returning the session so a host can inspect state or archives. The session is owned by the returned wiring; callers must not construct a second one over the same directory.

func RebuildFromHistory

func RebuildFromHistory(msgs []llm.Message, o Options) (*Session, error)

RebuildFromHistory reconstructs compression state by replaying the Compress calls recorded in a conversation.

It is the fallback when state.json is missing — imported, corrupted, or never written. No model is involved: the summaries live in the recorded call arguments, so replaying returns the byte-identical text rather than a fresh approximation of it.

Existing archives are adopted rather than rewritten. They are historical fact; a re-render would be a different file describing the same moment.

func (*Session) ArchiveRoot

func (s *Session) ArchiveRoot() string

ArchiveRoot is where this session's archives and state live.

func (*Session) Config

func (s *Session) Config() noa.Config

Config returns the resolved configuration.

func (*Session) State

func (s *Session) State() noa.CompressionState

State returns a copy of the current compression state.

func (*Session) View

func (s *Session) View(msgs []llm.Message) []llm.Message

View projects the history for one request.

Pure with respect to msgs: the returned slice is for this request only and is never written back. Message identity is a content hash, so writing a tagged or truncated body back would change every id derived from it.

type Sidecar

type Sidecar struct {
	// Signatures holds each thinking block's provider signature, keyed by core
	// id. Anthropic validates it on replay, so it must come back byte-identical.
	Signatures map[string]string
	// Origins maps a core id back to its source position.
	Origins map[string]origin
	// ToolResultIsError preserves the error flag of a tool result.
	ToolResultIsError map[string]bool
}

Sidecar carries what the flat representation cannot: provider-internal data that must survive the round trip untouched.

func Project

func Project(msgs []llm.Message) ([]noa.CoreMessage, *Sidecar)

Project flattens the host's history into noa's representation.

Boundary inheritance: a session that previously ran the built-in compaction carries a compact_boundary, and everything before it has already been summarised into the message that follows. noa's View does not go through llm.MessagesForAPI, so without this slice those pre-boundary originals would come back — alongside the summary that replaced them, doubling the context.

type StateStore

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

StateStore persists compression state beside the archives.

func NewStateStore

func NewStateStore(root string) *StateStore

NewStateStore returns a store writing to <root>/state.json. An empty root yields a memory-only store.

func (*StateStore) Exists

func (s *StateStore) Exists() bool

Exists reports whether a persisted state file is present.

func (*StateStore) Load

func (s *StateStore) Load(sessionID, archiveRoot string) (noa.CompressionState, error)

Load returns the state, preferring the in-process cache.

func (*StateStore) Save

func (s *StateStore) Save(st noa.CompressionState) error

Save records the state.

The cache is updated BEFORE the early return for a location-less store. A store that skipped the cache when it had no file would hand back a zero state on the next load, and the model would re-compress the same range every turn.

Jump to

Keyboard shortcuts

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