Merge pull request #7839 from clearnature/feat/output-budget-128k-compaction

feat: raise output budgets to 128K + cache-aware context projection / 提升输出预算与缓存感知惰性压缩
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-07 19:28:59 +08:00
commit 128b34b63b
36 files changed
+3142 -837

No files matched your search

@@ -0,0 +1,114 @@
# Cache-Aware Context Projection 与惰性压缩
> 日期:2026-08-07
> 状态:当前实现说明
> 核心约束:canonical transcript 是永久事实源;缓存状态只影响成本策略,不直接触发历史改写。
## 一、问题与目标
长会话需要同时满足两个目标:
1. 保留完整历史,以支持恢复、回退、分支和审计;
2. 在模型上下文接近上限时,构造更短且稳定的 provider-visible 请求。
旧路径把 cache TTL 过期与 session 压缩绑定:cold resume 会同步压缩并重写历史。这既把成本信号变成了数据变更信号,也会在用户只是恢复会话时破坏原有前缀。
当前设计将两者拆开:
```text
canonical transcript (Session.Messages,普通压缩永不改写)
|
+-- model-visible context projection
|
+-- cache state (warm/cold/unknown,仅参与成本与观测)
```
## 二、持久化边界
### Canonical transcript
- `Session.Messages` 始终保存完整 transcript。
- 普通 compaction、cold resume、tool prune/snip 不删除或替换 canonical 消息。
- rewind、fork、branch 仍以 canonical 为事实源。
### Context projection sidecar
- projection 存储在 `<session>.context.json`,不改变原 session 文件格式。
- sidecar 保存 projection、covered prefix fingerprint、transcript/projection version、prompt cache key、cache 状态和压缩 telemetry。
- 删除 session 时 sidecar 纳入同一删除清单。
- 老版本不知道 sidecar 时仍可读取完整 session;新版本遇到缺失、旧 schema 或校验失败的 sidecar 会安全重建。
## 三、运行时行为
### Resume 只记录缓存状态
恢复会话时,根据 provider TTL 和最后活动时间记录 `warm`、`cold` 或 `unknown`。Resume 路径不会调用 `Compact`、`SnapshotRewrite` 或 `PruneStaleToolResults`,也不会修改 canonical transcript。
### Preflight 惰性生成 projection
每次模型请求前,`contextPreflight` 根据当前 token 压力判断是否需要 projection:
- 未达到压力阈值:继续发送 append-only canonical view;
- 达到压缩阈值:尝试生成并安装 projection;
- 达到 force 阈值但没有可折叠内容:在非 tool loop 中返回可重试的 `ErrCompactionRequired`;
- 摘要失败:不写 mechanical marker,不安装半成品,也不改写 canonical;
- tool loop 进行中:只发 notice,由后续 preflight/stuck guard 处理,避免中断工具调用配对。
### Provider-visible 顺序
projection 使用稳定顺序:
```text
system
-> 确定性的早期 user turns
-> 一条 rolling summary
-> 必须保留的消息
-> recent tail
```
早期 user turn 的资格使用固定 token/char 估算和 context-window 上限,不依赖最近一次 provider usage。动态 usage 校准只用于 tail sizing 等不决定消息身份的估算,因此 projection 激活后,早期前缀不会因 canonical/projection 统计口径变化而漂移。
旧 summary 会进入下一次 fold,由新 summary 滚动吸收;provider-visible projection 始终只保留一条 summary,不会形成无限摘要链。原 summary 仍保留在 canonical transcript 中。
## 四、有效性与失效
projection 采用 fail-closed 校验:
- `CoveredPrefixHash` 对 `ModelMessages(canonical[:CoveredCount])` 的完整 provider-visible 内容生成稳定 fingerprint,覆盖图片、reasoning 元数据、Responses items、tool call ID/name/arguments/thought signature;
- `PromptCacheKey` 必须存在,并严格匹配 `workspace|session lineage|model`;
- 缺少 fingerprint、前缀被 edit/rewrite、切换模型或 lineage 时,内存 projection 立即失效;
- rewind、fork、branch、snip/prune 和显式范围摘要会使相关 projection 失效或隔离。
加载时发现某模型的 sidecar key 不匹配,只丢弃当前内存状态,不删除磁盘文件,避免破坏其它模型仍可使用的状态。
## 五、Provider compaction 与失败策略
Provider 接口已定义:
- `NativeCompactor`;
- `CompactionRequest` / `CompactionResult`;
- `CompactionCapabilities`;
- `ErrCompactionUnsupported`。
当前 Responses vendor 明确返回 unsupported,并回退到 Reasonix 的摘要路径。摘要首次失败后的重试会聚合两次 attempt 的 usage 与 request count,供成本和 telemetry 使用。
Anthropic、DeepSeek 等原生 compaction endpoint 尚未接入;能力接口不代表这些 endpoint 已经可用。
## 六、缓存影响
- 正向影响:cold resume 不再为了 TTL 状态改写历史,缓存仍 warm 时可以继续复用原 append-only 前缀。
- 预期 miss:首次在高压力下激活 projection 时,请求前缀会从 canonical 切换为 `summary + tail`,因此会发生一次预期的 cache miss。
- 稳定性:激活后,确定性的早期轮次、单条 rolling summary、稳定 cache key 和 fail-closed fingerprint 降低后续无意义的前缀漂移。
- 不确定点:token 估算可能使 preflight 比旧路径更早或更晚触发摘要,需要通过 telemetry 持续观察 break-even 成本。
## 七、明确未实现的后续
以下能力不属于当前阶段,不能按已落地行为依赖:
1. Anthropic/DeepSeek 原生 compaction endpoint;
2. compaction 后调用 `SaveKnowledge` 或写入 EventChain;
3. 依靠 EventChain 完成跨 session 的 L2 自动恢复;
4. feature flag 观测期、旧兼容路径的最终清理;
5. 完整 break-even 成本 dashboard 聚合。
这些后续必须分别设计失败原子性、持久化兼容、缓存影响和 provider 能力探测,不能重新把 cache TTL 与 canonical transcript 改写绑定。
+39 -108
View File
@@ -526,17 +526,22 @@ type Agent struct {
// under the window (consecutiveCompacts crosses the limit), so auto-compaction
// pauses instead of looping. softCompactNoticed gates the one-shot soft-ratio
// notice so it fires once per approach, not every turn.
contextWindow int
softCompactRatio float64
toolResultSnipRatio float64
compactRatio float64
compactForceRatio float64
softCompactNoticed bool
recentKeep int
archiveDir string
keepPolicy KeepPolicy
compactStuck bool
consecutiveCompacts int
contextWindow int
softCompactRatio float64
toolResultSnipRatio float64
compactRatio float64
compactForceRatio float64
softCompactNoticed bool
recentKeep int
archiveDir string
keepPolicy KeepPolicy
compactStuck bool
consecutiveCompacts int
sessionPath string // bound transcript path for projection sidecars
workspaceID string // stable prompt-cache lineage component
cacheState string // warm/cold/unknown; never provider-visible
compactionState CompactionState
strictAlternatingRoles bool // coalesce projection user runs for strict providers
// activeTurnCreatedAt identifies the real/synthetic user message that began
// the currently running turn. Compaction may rewrite older history while a
// tool loop is active, but it must keep this message and everything after it
@@ -782,6 +787,8 @@ func (a *Agent) SetSession(s *Session) {
a.missingReasoningHealthyStreak = 0
a.repeatFailureCounts = nil
a.repeatFailureScope = ""
a.compactionState = CompactionState{} // lineage change; disk reloaded on Resume
a.cacheState = CacheStateUnknown
if s != nil {
a.rebuildTodoState(s.Snapshot())
}
@@ -969,12 +976,10 @@ func (a *Agent) steerQueueLen() int {
// fires (e.g. 0.8). The status line uses it to show headroom to the next compact.
func (a *Agent) CompactRatio() float64 { return a.compactRatio }
// CompactNow runs one compaction pass immediately, regardless of the
// usage-ratio threshold maybeCompact normally honours. Used by the chat
// TUI's `/compact` command so the user can reset the prefix before it
// naturally fills up.
// CompactNow forces one projection compaction (canonical transcript untouched).
func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
return a.compact(ctx, "manual", instructions, true)
_, err := a.compactToProjection(ctx, "manual", instructions, true)
return err
}
// Options configures an Agent.
@@ -1025,14 +1030,17 @@ type Options struct {
// Context management. ContextWindow <= 0 disables compaction. Ratios and
// RecentKeep fall back to defaults when unset.
ContextWindow int
SoftCompactRatio float64
ToolResultSnipRatio float64
CompactRatio float64
CompactForceRatio float64
RecentKeep int
ArchiveDir string
KeepPolicy KeepPolicy
ContextWindow int
SoftCompactRatio float64
ToolResultSnipRatio float64
CompactRatio float64
CompactForceRatio float64
RecentKeep int
ArchiveDir string
KeepPolicy KeepPolicy
SessionPath string // projection sidecar path; empty = memory only
WorkspaceID string // prompt-cache lineage component
StrictAlternatingRoles bool // merge adjacent projection user turns for strict providers
// Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil
// disables hook firing.
@@ -1236,10 +1244,17 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
recentKeep: opts.RecentKeep,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
sessionPath: strings.TrimSpace(opts.SessionPath),
workspaceID: strings.TrimSpace(opts.WorkspaceID),
cacheState: CacheStateUnknown,
strictAlternatingRoles: opts.StrictAlternatingRoles,
subagentDepth: subagentDepth,
maxSubagentDepth: maxSubagentDepth,
mutationObserver: opts.MutationObserver,
}
if a.sessionPath != "" {
a.LoadProjectionSidecar(a.sessionPath)
}
a.SetResponseLanguage(opts.ResponseLanguage)
a.SetReasoningLanguage(opts.ReasoningLanguage)
return a
@@ -2256,90 +2271,6 @@ func toolBudgetNoticeText() string {
return "Tool round limit reached; asking the assistant to summarize progress."
}
// samplingRequest is a once-prepared, frozen provider request for one model
// round. All stream retries replay this exact payload — no synthetic recovery
// messages, no schema reorder, no previous_response_id drift from failed attempts.
type samplingRequest struct {
req provider.Request
}
// prepareSamplingRequest runs interceptors and schema fetch once per model
// round. Callers deep-copy via freezeProviderRequest before each Stream so
// providers cannot mutate the shared freeze across retries.
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
requestMessages := append([]provider.Message(nil), provider.ModelMessages(a.session.Messages)...)
for i := range requestMessages {
requestMessages[i].CreatedAt = 0
}
// context.prepare: extensions may rewrite the message copy feeding THIS
// request. The session log is never touched — the replacement is
// ephemeral, so the next request starts from the unmodified history and
// the prompt-cache prefix stays intact across turns.
requestMessages, err := a.interceptContextPrepare(ctx, requestMessages)
if err != nil {
return samplingRequest{}, err
}
req := provider.Request{
Messages: requestMessages,
Tools: a.tools.Schemas(),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
}
// provider.request: the fully assembled request gets one last ruling
// (revalidated by the payload registry) before it goes on the wire.
req, err = a.interceptProviderRequest(ctx, req)
if err != nil {
return samplingRequest{}, err
}
return samplingRequest{req: freezeProviderRequest(req)}, nil
}
// freezeProviderRequest deep-copies the provider-visible request surface so
// retries share identical messages, tools order, temperature, and format.
func freezeProviderRequest(req provider.Request) provider.Request {
out := req
if len(req.Messages) > 0 {
out.Messages = append([]provider.Message(nil), req.Messages...)
for i := range out.Messages {
if len(out.Messages[i].ToolCalls) > 0 {
out.Messages[i].ToolCalls = append([]provider.ToolCall(nil), out.Messages[i].ToolCalls...)
}
if len(out.Messages[i].Images) > 0 {
out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
}
if len(out.Messages[i].ResponsesItems) > 0 {
items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
for j, item := range out.Messages[i].ResponsesItems {
items[j] = append(json.RawMessage(nil), item...)
}
out.Messages[i].ResponsesItems = items
}
}
}
if len(req.Tools) > 0 {
out.Tools = make([]provider.ToolSchema, len(req.Tools))
for i, schema := range req.Tools {
out.Tools[i] = schema
if len(schema.Parameters) > 0 {
out.Tools[i].Parameters = append(json.RawMessage(nil), schema.Parameters...)
}
}
}
if req.Temperature != nil {
t := *req.Temperature
out.Temperature = &t
}
if req.ResponseFormat != nil {
rf := *req.ResponseFormat
out.ResponseFormat = &rf
}
return out
}
// stream runs one completion, emitting reasoning and text deltas as typed
// events and collecting complete tool calls. A Message event closes the text
// stream so a sink can re-render the streamed raw text as styled markdown. The
+99 -200
View File
@@ -34,6 +34,7 @@ const (
fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate
maxPinnedFirstUserTokens = 1500 // ceiling on pinning the first user turn verbatim; larger first turns (pasted content) stay foldable
pinnedFirstUserWindowFrac = 0.15 // and never pin a first turn worth more than this fraction of the window
maxKeepSmallUserTurns = 20 // position-fixed keep window for small user turns in the fold region; the first N survive verbatim, older ones fold (prefix byte-stable, never "latest N")
)
// summaryTag wraps the compaction summary so the model can distinguish it from
@@ -94,9 +95,10 @@ func (a *Agent) compactThresholds() (soft, snip, high int) {
return soft, snip, high
}
// maybeCompact compacts the session when the last turn's prompt has grown to the
// configured fraction of the context window. It is a no-op when compaction is
// disabled (no window) or usage is unavailable.
// maybeCompact compacts into a context projection when the last turn's prompt
// has grown to the configured fraction of the context window. It never rewrites
// the canonical transcript. No-op when compaction is disabled or usage is
// unavailable.
func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
if a.contextWindow <= 0 || u == nil || u.PromptTokens == 0 {
return
@@ -122,11 +124,9 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
return
}
if u.PromptTokens >= snip && u.PromptTokens < high {
ratio := a.tokPerChar()
if st, err := a.SnipStaleToolResults(); err == nil && st.Results > 0 {
saved := int(float64(st.SavedChars) * ratio)
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"snipped %d stale tool results (~%d tokens est.) before compaction", st.Results, saved)})
// Snip only into a projection view — never rewrite the canonical log.
if err := a.snipToProjection(ctx); err != nil {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context snip skipped for now.", Detail: err.Error()})
}
return
}
@@ -137,18 +137,24 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
return
}
force := u.PromptTokens >= int(float64(a.contextWindow)*a.compactForceRatio)
// Prune before folding: when eliding stale tool results alone clears the
// trigger, this turn's (paid) summarize call is skipped entirely.
// Projection-only prune before folding. Install the pruned view first so the
// next request (and its real usage) can measure whether a paid summarize is
// still needed — never rewrite the canonical transcript.
ratio := a.tokPerChar()
if st, err := a.PruneStaleToolResults(); err == nil && st.Results > 0 {
saved := int(float64(st.SavedChars) * ratio)
msgs := a.session.Messages
pruned, pst := a.applyToolResultMaintenanceView(msgs, toolResultPrune)
if pst.Results > 0 {
saved := int(float64(pst.SavedChars) * ratio)
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"pruned %d stale tool results (~%d tokens est.) before compaction", st.Results, saved)})
if !force && u.PromptTokens-saved < high {
"pruned %d stale tool results (~%d tokens est.) before compaction", pst.Results, saved)})
_ = a.installPruneProjection(pruned, pst)
if !force {
// Defer summarization until a later turn still reports pressure under
// the pruned projection. Force-ratio turns still fold immediately.
return
}
}
if err := a.compact(ctx, "auto", "", force); err != nil {
if _, err := a.compactToProjection(ctx, "auto", "", force); err != nil {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context cleanup skipped for now.", Detail: fmt.Sprintf("compaction skipped: %v", err)})
return
}
@@ -214,141 +220,6 @@ func estimateTextTokens(s string) int {
return byBytes
}
// compact summarizes the older middle of the session and replaces it in place:
// the session becomes system + summary + recent tail. The dropped originals are
// archived first, so the full history stays traceable. trigger is "auto" (the
// window threshold) or "manual" (/compact); it rides the Compaction events so a
// frontend can label the card. instructions is optional extra summary guidance
// (the user's `/compact <focus>` text); a PreCompact hook can contribute more.
// force bypasses the fold-economics skip (manual /compact and the force-ratio
// high-water mark always compact). A Started event is emitted before the (network)
// summarize so the UI can show a "compacting…" placeholder, and a Done event
// (carrying the summary) replaces it.
func (a *Agent) compact(ctx context.Context, trigger, instructions string, force bool) error {
msgs := a.session.Messages
head, start, ok := a.planCompaction(msgs, minCompactMessages)
if !ok {
// A single huge message can still be worth folding. Keep the normal
// message-count guard for small histories, but let content size decide
// whether a one-message region has real compaction value.
head, start, ok = a.planCompaction(msgs, 1)
}
if !ok {
return nil // recent tail already covers everything worth keeping
}
// A controller in-flight marker records the pre-turn message count, but a
// compaction rewrites message indexes. Keep the entire active turn outside
// the fold so completed tool call/result pairs remain available for a later
// cancellation or crash recovery instead of surviving only as prose in a
// summary.
if active := a.activeTurnStart(msgs); active >= head && active < start {
start = active
if start <= head {
return nil
}
}
region := msgs[head:start]
// Base layer: every small user turn in the region is kept verbatim (the
// deterministic floor — a fact the user stated is never summarized away,
// wherever in the session they said it); only the rest folds into the digest.
kept, fold := a.partitionFold(region)
if len(fold) == 0 {
return nil // nothing but kept user turns — a fold would save nothing
}
// Economic check on the foldable part (kept user turns don't count toward the
// savings): skip if too small to justify the call, unless force demands it.
if !force && !foldEconomics(fold) {
return nil
}
a.sink.Emit(event.Event{Kind: event.CompactionStarted, Compaction: event.Compaction{Trigger: trigger}})
// A PreCompact hook can steer what the summary keeps; its stdout joins any
// explicit /compact <focus> text.
if a.hooks != nil {
if hookInstr := a.hooks.PreCompact(ctx, trigger); hookInstr != "" {
if instructions != "" {
instructions += "\n"
}
instructions += hookInstr
}
}
// compaction.prepare: extensions rule on the fold and the accumulated
// guidance (hook + /compact focus) for THIS pass only. A block skips the
// pass; the caller surfaces the reason through its usual notice path.
var err error
fold, instructions, err = a.interceptCompactionPrepare(ctx, fold, instructions)
if err != nil {
a.emitCompactionAborted(trigger)
return err
}
if len(fold) == 0 {
a.emitCompactionAborted(trigger)
return nil // the extension replaced the fold with nothing to fold
}
archived := ""
if a.archiveDir != "" {
path, err := archiveMessages(a.archiveDir, fold)
if err != nil {
a.emitCompactionAborted(trigger)
return fmt.Errorf("archive: %w", err)
}
archived = path
}
// The digest covers only the foldable work; kept user turns and prior digests
// are spliced back verbatim, so a fact that reached a digest once is never
// re-summarized away and the user's own words are never touched. Digests
// accumulate (small) rather than collapsing into one lossy rolling summary.
summary, err := a.summarizeWithRetry(ctx, fold, instructions)
if err != nil {
// Mechanical fold: the foldable region is already archived, so stand in a
// deterministic marker rather than aborting. /compact then always frees
// context (and auto-compaction can't loop on a still-full window); the
// verbatim user turns kept above are untouched.
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context was compacted without a generated summary.", Detail: "compaction summary unavailable (" + err.Error() + "); folded mechanically"})
summary = mechanicalFoldDigest(len(fold), archived)
}
// compaction.complete: extensions rule on the produced summary before it
// is written into the session; a replacement is persisted as the summary.
summary, err = a.interceptCompactionComplete(ctx, summary)
if err != nil {
a.emitCompactionAborted(trigger)
return err
}
compacted := make([]provider.Message, 0, head+len(kept)+1+len(msgs)-start)
compacted = append(compacted, msgs[:head]...)
compacted = append(compacted, kept...)
compacted = append(compacted, provider.Message{
Role: provider.RoleUser,
Content: summaryTagOpen + "\n" +
"Summary of earlier conversation (older messages were compacted to save context):\n" +
summary + "\n" +
summaryTagClose,
})
compacted = append(compacted, msgs[start:]...)
a.session.Rewrite(compacted, "compact_"+trigger)
a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{
Trigger: trigger, Messages: len(fold), Summary: summary, Archive: archived,
}})
return nil
}
// emitCompactionAborted resolves a "compacting…" placeholder when a pass fails
// after the Started event: a Done with no summary tells a frontend to drop the
// placeholder. The caller still surfaces the reason (a Notice), so this carries
// no text of its own.
func (a *Agent) emitCompactionAborted(trigger string) {
a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{Trigger: trigger}})
}
// SummarizeFrom replaces the messages from fromIdx onward with a single summary,
// keeping everything before it verbatim ("summarize from here"). fromIdx is a turn
// boundary (a user message), so the split never severs a tool_call/result pair —
@@ -365,7 +236,7 @@ func (a *Agent) SummarizeFrom(ctx context.Context, fromIdx int) error {
if a.archiveDir != "" {
_, _ = archiveMessages(a.archiveDir, region) // best-effort traceability
}
summary, err := a.summarize(ctx, region, "")
summary, _, err := a.summarize(ctx, region, "")
if err != nil {
return err
}
@@ -377,6 +248,8 @@ func (a *Agent) SummarizeFrom(ctx context.Context, fromIdx int) error {
})
next = append(next, localOnly...)
a.session.Rewrite(next, "summarize_from")
// Explicit range rewrites change lineage; drop any prior projection.
a.InvalidateProjection()
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: fmt.Sprintf("summarized %d later messages → summary", len(region))})
return nil
@@ -401,7 +274,7 @@ func (a *Agent) SummarizeUpTo(ctx context.Context, toIdx int) error {
if a.archiveDir != "" {
_, _ = archiveMessages(a.archiveDir, region)
}
summary, err := a.summarize(ctx, region, "")
summary, _, err := a.summarize(ctx, region, "")
if err != nil {
return err
}
@@ -414,6 +287,7 @@ func (a *Agent) SummarizeUpTo(ctx context.Context, toIdx int) error {
next = append(next, localOnly...)
next = append(next, msgs[toIdx:]...)
a.session.Rewrite(next, "summarize_up_to")
a.InvalidateProjection()
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: fmt.Sprintf("summarized %d earlier messages → summary", len(region))})
return nil
@@ -461,46 +335,86 @@ func isCompactionSummary(m provider.Message) bool {
// pinnedPrefixLen counts the leading messages a fold keeps verbatim: the system
// prompt, the first user turn (its task + stated facts/constraints) when it is
// small enough to be a brief, and any prior summaries — so a fold never
// summarizes the user's facts away, and a later fold never re-summarizes an
// earlier summary into nothing (the drift that silently dropped user-stated facts
// after the second compaction). A large first turn (pasted content) stays
// foldable so pinning never starves the window.
// small enough to be a brief, and the NEWEST prior summary — so a fold never
// summarizes the user's facts away. Older summaries are NOT pinned: they enter
// the fold region and are merged into the next digest (A1 rolling merge), so a
// long session cannot accumulate an unbounded chain of digests. Merging re-feeds
// the old summary text into the summarizer, so the facts it captured survive in
// the new digest instead of being silently dropped.
func (a *Agent) pinnedPrefixLen(msgs []provider.Message) int {
i := 0
if i < len(msgs) && msgs[i].Role == provider.RoleSystem {
i++
}
if i < len(msgs) && msgs[i].Role == provider.RoleUser && !isCompactionSummary(msgs[i]) && a.pinnableUserTurn(msgs[i]) {
i++
}
for i < len(msgs) && isCompactionSummary(msgs[i]) {
if i < len(msgs) && msgs[i].Role == provider.RoleUser && !isCompactionSummary(msgs[i]) && a.fixedPinnableUserTurn(msgs[i]) {
i++
}
// The entire summary run stays inside the fold region; partitionFold keeps
// the NEWEST summary verbatim and folds the older ones into the next digest
// (A1 rolling merge) — re-feeding their text through the summarizer so a
// long session cannot accumulate an unbounded chain of digests.
return i
}
// pinnableUserTurn reports whether a user turn is small enough to keep verbatim. A
// turn larger than a brief (pasted content) folds like any other message so the
// kept-verbatim floor never starves the window.
func (a *Agent) pinnableUserTurn(m provider.Message) bool {
// fixedPinnableUserTurn reports whether a user turn is small enough to keep
// verbatim in a position-stable prefix. Identity decisions must not use the
// latest provider usage: after projection activates, that usage describes the
// projection while the canonical transcript remains larger, which would make
// the same turn drift in or out across compactions. Dynamic token calibration is
// reserved for non-identity estimates such as tail sizing.
func (a *Agent) fixedPinnableUserTurn(m provider.Message) bool {
budget := maxPinnedFirstUserTokens
if a.contextWindow > 0 {
if f := int(float64(a.contextWindow) * pinnedFirstUserWindowFrac); f < budget {
budget = f
}
}
return int(float64(msgChars(m))*a.tokPerChar()) <= budget
return int(float64(msgChars(m))*fallbackTokPerChar) <= budget
}
// partitionFold splits a compaction region into what is kept verbatim — small user
// turns (a fact the user stated is never summarized away) and prior digests (so a
// later fold never re-summarizes an earlier digest and drops the facts it already
// captured) — and the rest, which folds. Order within each group is preserved.
// turns (a fact the user stated is never summarized away) — and the rest, which
// folds. Order within each group is preserved.
//
// Prior digests inside the region are folded (not kept verbatim): the newest
// digest is already pinned by pinnedPrefixLen outside the region, so any summary
// seen here is an older one being merged into the next digest (A1 rolling merge).
// Merging re-feeds the old summary text into the summarizer, preserving its facts.
//
// The small-turn keep window is position-fixed (only the first keepSmallUserTurns
// small user turns in the region survive), never "the most recent N" — a dynamic
// tail window would move with every compaction and rewrite the kept prefix,
// cratering the server-side prefix cache (mental-seal: prefix stability is the
// first principle). A fixed prefix window keeps the surviving bytes identical
// across compactions; only the folded middle is replaced by a digest.
func (a *Agent) partitionFold(region []provider.Message) (kept, fold []provider.Message) {
policyKeep := keepIndexes(region, a.keepPolicy)
keptSmallUserTurns := 0
// The NEWEST summary in the region (the last one in the contiguous summary
// run) is kept verbatim; older digests fold into the next digest (A1 rolling
// merge) and are re-fed through the summarizer, so the digest chain cannot
// accumulate unboundedly.
lastSummary := -1
for i, m := range region {
if m.LocalOnly || policyKeep[i] || isCompactionSummary(m) || (m.Role == provider.RoleUser && a.pinnableUserTurn(m)) {
if isCompactionSummary(m) {
lastSummary = i
}
}
for i, m := range region {
keep := m.LocalOnly || policyKeep[i] || (isCompactionSummary(m) && i == lastSummary)
if !keep && m.Role == provider.RoleUser && !isCompactionSummary(m) && a.fixedPinnableUserTurn(m) {
// Position-fixed small-turn keep window: the first N small user turns
// in the region survive verbatim; older ones fold into the digest so
// a long session cannot accumulate unbounded verbatim user turns.
// N is fixed (not "latest N"), so the kept prefix stays byte-stable.
// Digests are excluded: they are governed by the A1 rolling merge
// (only the newest survives verbatim; older ones re-enter the fold).
if keptSmallUserTurns < maxKeepSmallUserTurns {
keep = true
}
keptSmallUserTurns++
}
if keep {
kept = append(kept, m)
} else {
fold = append(fold, m)
@@ -710,10 +624,9 @@ func charsOfMessages(msgs []provider.Message) int {
}
// summarize asks the executor's own provider (no tools) to distill the region
// into a briefing, returning the collected text. instructions, when non-empty,
// is appended to the system prompt as extra focus guidance (from /compact <focus>
// and/or a PreCompact hook).
func (a *Agent) summarize(ctx context.Context, region []provider.Message, instructions string) (string, error) {
// into a briefing. instructions is optional /compact focus + PreCompact text.
// Named returns so defer can attach RequestCount and still return usage.
func (a *Agent) summarize(ctx context.Context, region []provider.Message, instructions string) (summary string, usage *provider.Usage, err error) {
ctx, cancel := context.WithTimeout(ctx, summaryTimeout)
defer cancel()
ctx = provider.WithRequestAttemptCounter(ctx)
@@ -721,7 +634,6 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
if strings.TrimSpace(instructions) != "" {
sys += "\n\nAdditional focus for this compaction (prioritize keeping this):\n" + strings.TrimSpace(instructions)
}
var usage *provider.Usage
defer func() {
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
if usage != nil && (usage.TotalTokens > 0 || usage.RequestCount > 0) {
@@ -736,23 +648,21 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
Temperature: provider.OptionalTemperature(a.temperature),
})
if err != nil {
return "", err
return "", usage, err
}
// select on ctx.Done so a stalled stream (open but never delivering or closing)
// unblocks on timeout instead of pinning the "compacting…" placeholder forever.
var b strings.Builder
for {
select {
case <-ctx.Done():
return "", ctx.Err()
return "", usage, ctx.Err()
case chunk, ok := <-ch:
if !ok {
s := strings.TrimSpace(b.String())
if s == "" {
return "", fmt.Errorf("summarizer returned empty output")
return "", usage, fmt.Errorf("summarizer returned empty output")
}
return s, nil
return s, usage, nil
}
switch chunk.Type {
case provider.ChunkText:
@@ -760,32 +670,21 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
case provider.ChunkUsage:
usage = chunk.Usage
case provider.ChunkError:
return "", chunk.Err
return "", usage, chunk.Err
}
}
}
}
// summarizeWithRetry retries one non-timeout failure (a transient stream drop or
// rate blip); a timeout or a second failure returns so the caller folds
// mechanically rather than waiting again.
func (a *Agent) summarizeWithRetry(ctx context.Context, fold []provider.Message, instructions string) (string, error) {
summary, err := a.summarize(ctx, fold, instructions)
// summarizeWithRetry retries one non-timeout failure; timeout/cancel do not retry.
// Token and request counts from both attempts are merged into the returned Usage.
func (a *Agent) summarizeWithRetry(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) {
summary, usage, err := a.summarize(ctx, fold, instructions)
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return summary, err
return summary, usage, err
}
return a.summarize(ctx, fold, instructions)
}
// mechanicalFoldDigest is the deterministic stand-in used when the summarizer is
// unreachable: the foldable region is already archived, so the digest just notes
// the gap and points the model at the user for anything it needs from before it.
func mechanicalFoldDigest(n int, archive string) string {
where := "."
if archive != "" {
where = " (archived to " + archive + ")."
}
return fmt.Sprintf("%d earlier message(s) were folded here to free context, but the automatic summary was unavailable%s Ask the user if you need details from before this point.", n, where)
summary2, usage2, err2 := a.summarize(ctx, fold, instructions)
return summary2, mergeStreamUsage(usage, usage2), err2
}
// renderTranscript flattens messages into a readable transcript for summarization.
+6 -5
View File
@@ -181,9 +181,10 @@ func TestCompactionHealthyWindowNeverLoops(t *testing.T) {
}
// TestPruneKeepsToolHeavySessionBounded: when growth comes from tool results,
// pruning alone keeps the prompt under the trigger for the whole session — the
// paid summarize call never happens and the stuck guard never trips. 20 turns of
// ~3k-token blobs would otherwise cross 0.8×40000 around turn 11.
// projection-only pruning is the primary pressure valve. Occasional force-ratio
// summary folds may still fire when the recent tail alone is huge, but they must
// stay rare and must never trip the stuck guard. 20 turns of ~3k-token blobs
// would otherwise cross 0.8×40000 around turn 11 without maintenance.
func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
perTurn, paused, prunes := compactionsPerTurn(t, 40000, strings.Repeat("file line. ", 1100), "", 20)
@@ -193,8 +194,8 @@ func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
}
t.Logf("compactions per turn: %v (total %d), paused=%v, prunes=%d", perTurn, total, paused, prunes)
if total != 0 {
t.Errorf("compaction fired %d times; pruning should keep a tool-heavy session bounded without folding", total)
if total > 3 {
t.Errorf("compaction fired %d times; pruning should keep folds rare on a tool-heavy session", total)
}
if paused {
t.Errorf("auto-compaction paused; pruning should have prevented the stuck loop entirely")
+324
View File
@@ -0,0 +1,324 @@
package agent
import (
"context"
"errors"
"fmt"
"time"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// compact writes a context projection; trigger stays "auto"/"manual" for UI cards.
func (a *Agent) compact(ctx context.Context, trigger, instructions string, force bool) error {
_, err := a.compactToProjection(ctx, trigger, instructions, force)
return err
}
// compactToProjection summarizes the older middle of the session into a model-
// visible projection. The canonical transcript is never rewritten. force
// bypasses the fold-economics skip. CompactionNoop means no projection was
// installed (nothing to fold); callers at the force threshold must treat that
// as a hard failure rather than sending the oversized canonical prompt.
func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions string, force bool) (CompactionOutcome, error) {
msgs, transcriptVersion := a.session.snapshotMessagesVersion()
head, start, ok := a.planCompaction(msgs, minCompactMessages)
if !ok {
head, start, ok = a.planCompaction(msgs, 1)
}
if !ok {
return CompactionNoop, nil
}
if active := a.activeTurnStart(msgs); active >= head && active < start {
start = active
if start <= head {
return CompactionNoop, nil
}
}
region := msgs[head:start]
kept, fold := a.partitionFoldForProjection(region)
if len(fold) == 0 {
return CompactionNoop, nil
}
if !force && !foldEconomics(fold) {
return CompactionNoop, nil
}
a.sink.Emit(event.Event{Kind: event.CompactionStarted, Compaction: event.Compaction{Trigger: trigger}})
if a.hooks != nil {
if hookInstr := a.hooks.PreCompact(ctx, trigger); hookInstr != "" {
if instructions != "" {
instructions += "\n"
}
instructions += hookInstr
}
}
var err error
fold, instructions, err = a.interceptCompactionPrepare(ctx, fold, instructions)
if err != nil {
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
if len(fold) == 0 {
a.emitCompactionAborted(trigger)
return CompactionNoop, nil
}
archived := ""
if a.archiveDir != "" {
path, aerr := archiveMessages(a.archiveDir, fold)
if aerr != nil {
a.emitCompactionAborted(trigger)
return CompactionNoop, fmt.Errorf("archive: %w", aerr)
}
archived = path
}
sourceTokens := estimateMessagesTokens(provider.ModelMessages(msgs))
summary, mode, usage, providerReqID, err := a.runCompactionSummary(ctx, fold, instructions)
tele := CompactionTelemetry{
Trigger: trigger,
CacheState: a.CacheState(),
Mode: mode,
Native: mode == CompactionModeNative,
SourceTokens: sourceTokens,
ProviderRequestID: providerReqID,
}
if usage != nil {
tele.InputTokens = usage.PromptTokens
tele.OutputTokens = usage.CompletionTokens
tele.CacheHitTokens = usage.CacheHitTokens
tele.CacheMissTokens = usage.CacheMissTokens
tele.CacheWriteTokens = usage.CacheWriteTokens
tele.RequestCount = usage.RequestCount
if tele.RequestCount <= 0 {
tele.RequestCount = 1
}
}
if err != nil {
tele.Error = err.Error()
a.emitCompactionTelemetry(tele)
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
summary, err = a.interceptCompactionComplete(ctx, summary)
if err != nil {
tele.Error = err.Error()
a.emitCompactionTelemetry(tele)
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
early := a.fixedEarlyUserTurns(msgs, head)
projMsgs := make([]provider.Message, 0, head+len(early)+1+len(kept)+len(msgs)-start)
projMsgs = append(projMsgs, msgs[:head]...)
projMsgs = append(projMsgs, early...)
projMsgs = append(projMsgs, formatSummaryMessage(summary))
projMsgs = append(projMsgs, kept...)
projMsgs = append(projMsgs, msgs[start:]...)
projMsgs = provider.ModelMessages(projMsgs)
if a.strictAlternatingRoles {
projMsgs = coalesceProjectionUserRuns(projMsgs)
}
projTokens := estimateMessagesTokens(projMsgs)
tele.ProjectionTokens = projTokens
a.emitCompactionTelemetry(tele)
projVersion := a.compactionState.Projection.ProjectionVersion + 1
st := CompactionState{
SchemaVersion: compactionStateSchemaV1,
TranscriptVersion: transcriptVersion,
Projection: ContextProjection{
Messages: projMsgs,
TranscriptVersion: transcriptVersion,
ProjectionVersion: projVersion,
CoveredCount: len(msgs),
CoveredPrefixHash: coveredPrefixHash(msgs, len(msgs)),
SummaryHash: summaryContentHash(summary),
SourceTokens: sourceTokens,
ProjectionTokens: projTokens,
CreatedAt: time.Now().UTC(),
},
PromptCacheKey: a.currentPromptCacheKey(),
LastCacheState: a.CacheState(),
LastTrigger: trigger,
LastMode: mode,
LastSourceTokens: sourceTokens,
LastResultTokens: projTokens,
UpdatedAt: time.Now().UTC(),
}
if a.pricing != nil && usage != nil {
st.LastCompactionCost = a.pricing.Cost(usage)
}
if err := a.installProjection(st); err != nil {
a.emitCompactionAborted(trigger)
return CompactionNoop, fmt.Errorf("persist projection: %w", err)
}
a.session.NoteContentRewrite("compact_" + trigger)
a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{
Trigger: trigger, Messages: len(fold), Summary: summary, Archive: archived,
}})
return CompactionInstalled, nil
}
// partitionFoldForProjection is like partitionFold but prior digests join the
// fold so A1 rolling merge produces a single latest summary. Fixed early user
// turns are excluded from both kept and fold — the caller re-inserts them from
// the full transcript so their bytes stay position-stable.
func (a *Agent) partitionFoldForProjection(region []provider.Message) (kept, fold []provider.Message) {
policyKeep := keepIndexes(region, a.keepPolicy)
earlySeen := 0
const maxEarly = 3
for i, m := range region {
if m.LocalOnly {
continue
}
// Skip the fixed early small user turns — they are re-added from the
// full transcript after the summary so the prefix stays byte-stable.
if m.Role == provider.RoleUser && !isCompactionSummary(m) && a.fixedPinnableUserTurn(m) && earlySeen < maxEarly {
earlySeen++
continue
}
if isCompactionSummary(m) {
fold = append(fold, m)
continue
}
if policyKeep[i] {
kept = append(kept, m)
continue
}
if m.Role == provider.RoleUser && a.fixedPinnableUserTurn(m) {
// Additional small user turns beyond the fixed early window fold so
// the projection does not grow unbounded with every user fact.
fold = append(fold, m)
continue
}
fold = append(fold, m)
}
return kept, fold
}
// runCompactionSummary tries native compaction first, then summarizeWithRetry.
// On total failure it returns the error without a mechanical marker.
func (a *Agent) runCompactionSummary(ctx context.Context, fold []provider.Message, instructions string) (summary, mode string, usage *provider.Usage, providerReqID string, err error) {
if nc, ok := provider.AsNativeCompactor(a.prov); ok {
maxOut := 0
if cc, ok := provider.AsCompactionCapabler(a.prov); ok {
caps := cc.CompactionCapabilities()
maxOut = caps.CompactionOutputTokens
if maxOut <= 0 {
maxOut = caps.MaxOutputTokens
}
}
res, nerr := nc.Compact(ctx, provider.CompactionRequest{
Messages: fold,
Instructions: instructions,
MaxOutputTokens: maxOut,
PromptCacheKey: promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef),
SessionID: BranchID(a.sessionPath),
})
if nerr == nil && res.Valid() {
if res.Summary != "" {
return res.Summary, CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
// Provider returned a full projection; extract summary text if present,
// otherwise render the projection as the digest body.
if s := extractLatestSummary(res.Projection); s != "" {
return s, CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
return renderTranscript(res.Projection), CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
if nerr != nil && !errors.Is(nerr, provider.ErrCompactionUnsupported) {
// Hard native failure: still try ordinary summarize fallback.
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Native compaction unavailable; using summary fallback.", Detail: nerr.Error()})
}
}
summary, usage, err = a.summarizeWithRetry(ctx, fold, instructions)
if err != nil {
return "", CompactionModeSummarized, usage, "", err
}
return summary, CompactionModeSummarized, usage, "", nil
}
// snipToProjection builds a projection that only snips stale tool results.
func (a *Agent) snipToProjection(ctx context.Context) error {
_ = ctx
msgs, _ := a.session.snapshotMessagesVersion()
snipped, st := a.applyToolResultMaintenanceView(msgs, toolResultSnip)
if st.Results == 0 {
return nil
}
ratio := a.tokPerChar()
saved := int(float64(st.SavedChars) * ratio)
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"snipped %d stale tool results (~%d tokens est.) before compaction", st.Results, saved)})
return a.installPruneProjection(snipped, st)
}
// installPruneProjection stores a projection whose messages are a snipped/pruned
// view of the canonical transcript (no summarizer call).
func (a *Agent) installPruneProjection(view []provider.Message, st PruneStats) error {
msgs, version := a.session.snapshotMessagesVersion()
view = provider.ModelMessages(view)
src := estimateMessagesTokens(provider.ModelMessages(msgs))
dst := estimateMessagesTokens(view)
projVersion := a.compactionState.Projection.ProjectionVersion + 1
state := CompactionState{
SchemaVersion: compactionStateSchemaV1,
TranscriptVersion: version,
Projection: ContextProjection{
Messages: view,
TranscriptVersion: version,
ProjectionVersion: projVersion,
CoveredCount: len(msgs),
CoveredPrefixHash: coveredPrefixHash(msgs, len(msgs)),
SourceTokens: src,
ProjectionTokens: dst,
CreatedAt: time.Now().UTC(),
},
PromptCacheKey: a.currentPromptCacheKey(),
LastCacheState: a.CacheState(),
LastTrigger: CompactionTriggerSnip,
LastMode: CompactionModeSnip,
LastSourceTokens: src,
LastResultTokens: dst,
UpdatedAt: time.Now().UTC(),
}
_ = st
return a.installProjection(state)
}
// emitCompactionTelemetry records structured compaction observability without
// logging sensitive transcript content.
func (a *Agent) emitCompactionTelemetry(t CompactionTelemetry) {
detail := fmt.Sprintf("trigger=%s mode=%s cache=%s src=%d proj=%d in=%d out=%d hit=%d miss=%d write=%d reqs=%d",
t.Trigger, t.Mode, t.CacheState, t.SourceTokens, t.ProjectionTokens,
t.InputTokens, t.OutputTokens, t.CacheHitTokens, t.CacheMissTokens, t.CacheWriteTokens, t.RequestCount)
if t.ProviderRequestID != "" {
detail += " provider_request_id=" + t.ProviderRequestID
}
if t.Error != "" {
detail += " err_type=" + t.Error
}
level := event.LevelInfo
text := "compaction telemetry"
if t.Error != "" {
level = event.LevelWarn
text = "compaction failed"
}
a.sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail})
}
// emitCompactionAborted resolves a "compacting…" placeholder when a pass fails
// after the Started event: a Done with no summary tells a frontend to drop the
// placeholder. The caller still surfaces the reason (a Notice), so this carries
// no text of its own.
func (a *Agent) emitCompactionAborted(trigger string) {
a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{Trigger: trigger}})
}
+126 -329
View File
@@ -2,7 +2,7 @@ package agent
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reasonix/internal/event"
@@ -45,6 +45,9 @@ func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan p
return ch, nil
}
// visibleContext returns the model-visible projection when present, else the
// canonical transcript. Compaction tests assert against this view.
func TestTailStart(t *testing.T) {
// 10-char content → with tokPerChar 1.0, each non-empty message costs 10
// "tokens"; tool-call messages carry name+args instead.
@@ -131,10 +134,10 @@ func TestPinnedPrefixLen(t *testing.T) {
want int
}{
{"pins-system-and-small-task", 0, []provider.Message{sys, small, as, as}, 2},
{"also-pins-prior-summaries", 0, []provider.Message{sys, small, sum, sum, as}, 4},
{"summaries-are-not-pinned-A1-merge", 0, []provider.Message{sys, small, sum, sum, as}, 2},
{"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1},
{"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1},
{"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 2},
{"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -145,202 +148,6 @@ func TestPinnedPrefixLen(t *testing.T) {
}
}
func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
big := strings.Repeat("work output ", 100)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Both the pinned first turn and the mid-session fact survive verbatim — not as
// summary text — while the assistant/tool work between them is folded.
var pinnedFirst, keptMid bool
for _, m := range sess.Snapshot() {
if isCompactionSummary(m) {
continue
}
if m.Role == provider.RoleUser && m.Content == "first task" {
pinnedFirst = true
}
if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
keptMid = true
}
}
if !pinnedFirst || !keptMid {
t.Fatalf("user turns not kept verbatim (first=%v mid=%v): %+v", pinnedFirst, keptMid, sess.Snapshot())
}
if strings.Contains(strings.Join(snapshotContents(sess), " "), big) {
t.Errorf("assistant/tool work was not folded")
}
}
func snapshotContents(s *Session) []string {
msgs := s.Snapshot()
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Content
}
return out
}
func TestRunCompactsAfterFinalAnswer(t *testing.T) {
// A turn that ends with a final answer (no trailing tool batch) must still
// compact when the context is over the trigger; otherwise a large context
// carries into the next turn un-folded and overflows the model window.
big := strings.Repeat("old work ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleAssistant, Content: big},
}}
a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess,
Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.Run(context.Background(), "what's the status?"); err != nil {
t.Fatalf("run: %v", err)
}
if got := sess.RewriteVersion(); got != 1 {
t.Fatalf("final-answer turn over the trigger did not compact: rewrite version = %d, want 1", got)
}
}
func TestCompactKeepsPriorDigests(t *testing.T) {
// A prior digest anywhere in the folded region is kept verbatim, not
// re-summarized — so a fact it already captured is not lost to re-fold drift.
priorDigest := summaryTagOpen + "\n## Standing facts\n- db is orion_prod_42\n" + summaryTagClose
big := strings.Repeat("work output ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big}, // breaks leading-summary contiguity
{Role: provider.RoleUser, Content: priorDigest},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(&fakeProvider{reply: "new digest"}, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// The fake summarizer returns "new digest" (no fact); the prior fact survives
// only because the prior digest was kept verbatim rather than re-folded.
var kept bool
for _, m := range sess.Snapshot() {
if strings.Contains(m.Content, "orion_prod_42") {
kept = true
}
}
if !kept {
t.Fatalf("prior digest re-summarized away: %+v", sess.Snapshot())
}
}
func TestCompactReplacesHistory(t *testing.T) {
prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
bigStep := strings.Repeat("important implementation detail ", 80)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task " + bigStep},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"},
{Role: provider.RoleAssistant, Content: "did a step"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
dir := t.TempDir()
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
if got := sess.RewriteVersion(); got != 1 {
t.Fatalf("rewrite version = %d, want 1", got)
}
// system + pinned first user turn + summary + last 2 verbatim.
if got := len(sess.Messages); got != 5 {
t.Fatalf("len = %d, want 5: %+v", got, sess.Messages)
}
if sess.Messages[0].Role != provider.RoleSystem {
t.Errorf("message 0 = %s, want system", sess.Messages[0].Role)
}
if task := sess.Messages[1]; task.Role != provider.RoleUser || !strings.HasPrefix(task.Content, "task ") {
t.Errorf("first user turn not pinned verbatim: %+v", task)
}
summary := sess.Messages[2]
if summary.Role != provider.RoleUser || !strings.Contains(summary.Content, "Summary of earlier") || !strings.Contains(summary.Content, "do X") {
t.Errorf("summary message = %+v", summary)
}
if sess.Messages[3].Content != "next" || sess.Messages[4].Content != "ok" {
t.Errorf("recent tail not preserved: %+v", sess.Messages[3:])
}
// The 3 dropped originals were archived, one JSON object per line (the task
// turn is pinned, not folded, so it is not among them).
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("archive dir: entries=%d err=%v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("read archive: %v", err)
}
if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 3 {
t.Errorf("archived %d lines, want 3:\n%s", lines, data)
}
if !strings.HasSuffix(entries[0].Name(), ".jsonl") {
t.Errorf("archive name = %q, want .jsonl", entries[0].Name())
}
}
func TestCompactKeepsErrorMessages(t *testing.T) {
prov := &fakeProvider{reply: "- normal work summarized"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: "continued"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
if len(sess.Messages) < 6 {
t.Fatalf("session unexpectedly short after compact: %+v", sess.Messages)
}
if sess.Messages[2].Role != provider.RoleAssistant || len(sess.Messages[2].ToolCalls) != 1 {
t.Fatalf("kept error lost its assistant tool call: %+v", sess.Messages)
}
if sess.Messages[3].Role != provider.RoleTool || sess.Messages[3].Content != "error: command failed" {
t.Fatalf("error tool result not kept verbatim: %+v", sess.Messages)
}
if strings.Contains(prov.got[1].Content, "error: command failed") {
t.Fatalf("kept error was still folded into summary input:\n%s", prov.got[1].Content)
}
}
func TestKeepIndexesKeepsSiblingToolResultsForKeptError(t *testing.T) {
region := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
@@ -378,37 +185,6 @@ func TestKeepIndexesScopesPolicyAfterLatestSummary(t *testing.T) {
}
}
func TestCompactKeepsUserMarkedMessages(t *testing.T) {
prov := &fakeProvider{reply: "- unmarked work summarized"}
marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleUser, Content: marked},
{Role: provider.RoleAssistant, Content: "worked"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
var kept bool
for _, m := range sess.Messages {
if m.Content == marked {
kept = true
break
}
}
if !kept {
t.Fatalf("marked message not kept verbatim: %+v", sess.Messages)
}
if strings.Contains(prov.got[1].Content, "exact requirement") {
t.Fatalf("marked message was still folded into summary input:\n%s", prov.got[1].Content)
}
}
func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) {
region := []provider.Message{
{Role: provider.RoleAssistant, Content: "[keep] assistant output"},
@@ -428,46 +204,13 @@ func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) {
// TestCompactFallsBackToMechanicalFoldWhenSummaryFails: when the summarizer is
// unreachable, /compact must still free context (fold mechanically) and surface a
// card, not hang or abort leaving a full window.
func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) {
prov := &fakeProvider{streamErr: errors.New("provider down")}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: "step one"},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: "step two"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
var got []event.Event
sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, sink)
before := len(sess.Messages)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact should fall back, not error: %v", err)
}
if len(sess.Messages) >= before {
t.Fatalf("session not compacted on summarizer failure: %d -> %d", before, len(sess.Messages))
}
var done *event.Compaction
for i := range got {
if got[i].Kind == event.CompactionDone {
done = &got[i].Compaction
}
}
if done == nil || !strings.Contains(done.Summary, "summary was unavailable") {
t.Fatalf("CompactionDone = %+v, want a mechanical-fold summary", done)
}
}
// TestSummarizeRespectsContextCancel: a stalled stream (open but never closing)
// must unblock on context cancellation instead of pinning compaction forever.
func TestSummarizeRespectsContextCancel(t *testing.T) {
a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &Session{}, Options{}, event.Discard)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil {
if _, _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil {
t.Fatal("summarize must return when ctx is cancelled, not hang")
}
}
@@ -551,59 +294,6 @@ func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) {
}
}
func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) {
prov := &fakeProvider{reply: "- summary"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "a"},
{Role: provider.RoleAssistant, Content: "b"},
{Role: provider.RoleUser, Content: "c"},
{Role: provider.RoleAssistant, Content: "d"},
{Role: provider.RoleUser, Content: "e"},
{Role: provider.RoleAssistant, Content: "f"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
before := CaptureShape("sys", nil, sess.RewriteVersion())
if err := a.compact(context.Background(), "auto", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
after := CaptureShape("sys", nil, sess.RewriteVersion())
reasons := sess.DrainContentRewriteReasons()
diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}, reasons)
if !diag.PrefixChanged {
t.Fatalf("diagnostics should report prefix change: %+v", diag)
}
if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "compact_auto" {
t.Fatalf("change reasons = %v, want [compact_auto]", diag.PrefixChangeReasons)
}
}
func TestCompactFoldsSingleLargeMessage(t *testing.T) {
prov := &fakeProvider{reply: "- captured the large file contents"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("large output line\n", 500)},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "auto", "", false); err != nil {
t.Fatalf("compact: %v", err)
}
if got := len(sess.Messages); got != 4 {
t.Fatalf("len = %d, want 4: %+v", got, sess.Messages)
}
if !strings.Contains(sess.Messages[1].Content, "large file contents") {
t.Fatalf("single large message was not summarized: %+v", sess.Messages)
}
if len(prov.got) == 0 || !strings.Contains(prov.got[1].Content, "large output line") {
t.Fatalf("summarizer did not receive the large message: %+v", prov.got)
}
}
func TestCompactSkipsSingleSmallMessage(t *testing.T) {
prov := &fakeProvider{reply: "- should not be called"}
sess := &Session{Messages: []provider.Message{
@@ -679,8 +369,12 @@ func TestMaybeCompactThreshold(t *testing.T) {
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80})
if !strings.Contains(sess.Messages[1].Content, "Summary of earlier") {
t.Errorf("compact threshold should fold the large early message, got: %+v", sess.Messages[1])
if !hasCompactionSummary(visibleContext(a)) {
t.Errorf("compact threshold should fold the large early message into projection, got: %+v", visibleContext(a))
}
// Canonical transcript remains full.
if len(sess.Messages) != 7 {
t.Errorf("canonical should stay full after projection compact, len=%d", len(sess.Messages))
}
// No context window: compaction disabled.
@@ -704,17 +398,16 @@ func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 90})
// The first user turn is pinned (index 1) and the token-budgeted tail keeps
// next, ok, so only "small old answer" folds — force bypasses the economics
// skip and installs a summary at index 2, leaving the count at 5.
// Force bypasses economics and installs a projection summary; canonical stays.
if got := len(sess.Messages); got != 5 {
t.Fatalf("len = %d, want 5 after forced single-message fold: %+v", got, sess.Messages)
t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
}
if sess.Messages[1].Content != "small old request" {
t.Fatalf("first user turn not pinned verbatim: %+v", sess.Messages[1])
t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1])
}
if !strings.Contains(sess.Messages[2].Content, "forced summary") {
t.Fatalf("forced compact did not install summary: %+v", sess.Messages)
proj := visibleContext(a)
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "forced summary") {
t.Fatalf("forced compact did not install summary projection: %+v", proj)
}
if len(prov.got) == 0 {
t.Fatalf("summarizer was not called at force ceiling")
@@ -752,10 +445,11 @@ func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80})
if got := len(sess.Messages); got != 4 {
t.Fatalf("len = %d, want 4: %+v", got, sess.Messages)
t.Fatalf("canonical len = %d, want 4: %+v", got, sess.Messages)
}
if !strings.Contains(sess.Messages[1].Content, "single large summary") {
t.Fatalf("single large message was not compacted at threshold: %+v", sess.Messages)
proj := visibleContext(a)
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") {
t.Fatalf("single large message was not compacted into projection: %+v", proj)
}
}
@@ -1000,3 +694,106 @@ func TestMaybeCompactStillLatchesWhenPromptStaysAboveTrigger(t *testing.T) {
t.Fatalf("two consecutive over-trigger compactions must still latch: consecutiveCompacts=%d", a.consecutiveCompacts)
}
}
func TestPartitionFoldSmallTurnWindowIsPositionFixed(t *testing.T) {
// 25 small user turns in the region: the first 20 must be kept verbatim,
// the last 5 must fold. The window is position-fixed (first N), never
// "the most recent N" — a dynamic tail would rewrite the kept prefix on
// every compaction and crater the server-side prefix cache.
a := &Agent{}
var region []provider.Message
for i := range 25 {
region = append(region, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("small turn %d", i)})
}
kept, fold := a.partitionFold(region)
if len(kept) != maxKeepSmallUserTurns {
t.Fatalf("kept %d small user turns, want %d (position-fixed window)", len(kept), maxKeepSmallUserTurns)
}
if len(fold) != 5 {
t.Fatalf("folded %d turns, want 5 (turns beyond the fixed window)", len(fold))
}
// The kept turns must be the FIRST ones in order (positions 0..19).
for i := range maxKeepSmallUserTurns {
want := fmt.Sprintf("small turn %d", i)
if got := UserMessageText(kept[i]); got != want {
t.Fatalf("kept[%d]=%q, want %q — keep window must be the leading turns", i, got, want)
}
}
// Folded turns are the oldest beyond the window (positions 20..24).
for i, m := range fold {
want := fmt.Sprintf("small turn %d", 20+i)
if got := UserMessageText(m); got != want {
t.Fatalf("fold[%d]=%q, want %q", i, got, want)
}
}
}
func TestPartitionFoldLargeTurnsStillFold(t *testing.T) {
// Large user turns are not pinnable regardless of window position.
a := &Agent{}
region := []provider.Message{
{Role: provider.RoleUser, Content: strings.Repeat("big", 4000)}, // 12000 chars ×0.25 = 3000 > 1500 → not pinnable
{Role: provider.RoleUser, Content: "small"},
}
kept, fold := a.partitionFold(region)
if len(kept) != 1 || UserMessageText(kept[0]) != "small" {
t.Fatalf("kept=%+v, want only the small turn", kept)
}
if len(fold) != 1 {
t.Fatalf("fold=%d, want the large turn folded", len(fold))
}
}
func TestCompactRollsOldDigestsIntoNew(t *testing.T) {
// A1 rolling merge: prior digests enter the fold region and are merged into
// one new provider-visible summary. The canonical transcript stays intact.
oldDigest := summaryTagOpen + "\n" + strings.Repeat("old standing fact ", 60) + "\n" + summaryTagClose // >1500 chars → not pinnable
newestDigest := summaryTagOpen + "\nnewest digest\n" + summaryTagClose
big := strings.Repeat("work output ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleUser, Content: oldDigest}, // old digest, large → folds
{Role: provider.RoleUser, Content: newestDigest},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(&fakeProvider{reply: "merged digest"}, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
canonical := sess.Snapshot()
var oldDigestRetained bool
for _, m := range canonical {
if m.Content == oldDigest {
oldDigestRetained = true
}
}
if !oldDigestRetained {
t.Fatalf("canonical transcript lost old digest: %+v", canonical)
}
projection := visibleContext(a)
var summaryCount int
var generatedSummaryPresent bool
for _, m := range projection {
if isCompactionSummary(m) {
summaryCount++
if strings.Contains(m.Content, "merged digest") {
generatedSummaryPresent = true
}
}
if m.Content == oldDigest {
t.Fatalf("old digest survived verbatim in projection: %+v", projection)
}
}
if summaryCount != 1 {
t.Fatalf("projection summaries = %d, want exactly 1: %+v", summaryCount, projection)
}
if !generatedSummaryPresent {
t.Fatalf("generated rolling summary missing from projection: %+v", projection)
}
}
+28 -33
View File
@@ -1699,13 +1699,13 @@ func TestCoordinatorRunsExecutorWhenMarkerNotAlone(t *testing.T) {
// successful plan.
func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{ // the plan, with usage past the force-compaction watermark
{Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
{ // preflight compaction on the large filler history (estimate-based)
{Type: provider.ChunkText, Text: "- goal: prior filler\n- pending: plan the fix"},
{Type: provider.ChunkDone},
},
{ // the compaction summarizer call
{Type: provider.ChunkText, Text: "- goal: guard work\n- pending: none"},
{ // the plan turn after projection is in place
{Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 400, TotalTokens: 450}},
{Type: provider.ChunkDone},
},
}}
@@ -1719,9 +1719,9 @@ func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
// Preset enough planner history that the fold shrinks the session to (or
// below) its pre-turn length, which is what strands a boundary based on
// the pre-turn message count.
// Preset enough planner history that context preflight compacts before the
// plan stream. Canonical history stays intact; the handoff must still find
// the plan on the canonical transcript.
filler := strings.Repeat("planner history filler. ", 150)
for range 3 {
plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: filler})
@@ -1732,8 +1732,10 @@ func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if plannerSess.RewriteVersion() == 0 {
t.Fatal("test setup: planner compaction did not fire, the rewrite boundary is not exercised")
// Projection compaction no longer rewrites the planner session; handoff
// must still deliver the plan even when RewriteVersion stays 0.
if plannerSess.RewriteVersion() != 0 {
t.Fatalf("canonical rewrite version = %d, want 0", plannerSess.RewriteVersion())
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor never ran")
@@ -1836,22 +1838,20 @@ func TestCoordinatorPassesTurnContextToPlannerGate(t *testing.T) {
}
}
// TestCoordinatorFailedTurnRollbackKeepsCompaction pins the rewrite-aware
// rollback economics: when auto-compaction fires mid-turn (after a tool round)
// and the planner THEN fails, restoring the pre-turn snapshot would revert the
// compaction — wasting its summarizer call and re-growing the prompt. The
// rollback must instead keep the compacted log and only drop trailing plain
// user messages, so the next plan still starts from the folded history without
// consecutive user roles.
// TestCoordinatorFailedTurnRollbackKeepsCompaction pins rollback economics
// under projection compaction: when preflight/auto compaction fires and the
// planner then fails, restoring the pre-turn snapshot must not erase the
// projection or leave a dangling plain user turn that would produce
// consecutive user roles on the next plan.
func TestCoordinatorFailedTurnRollbackKeepsCompaction(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{ // tool round whose usage crosses the force-compaction watermark
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
{ // preflight compaction on large filler history
{Type: provider.ChunkText, Text: "- goal: guard work\n- pending: continue"},
{Type: provider.ChunkDone},
},
{ // the compaction summarizer call
{Type: provider.ChunkText, Text: "- goal: guard work\n- pending: continue"},
{ // tool round after projection is installed
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 400, TotalTokens: 450}},
{Type: provider.ChunkDone},
},
{ // the next planner round fails
@@ -1881,19 +1881,14 @@ func TestCoordinatorFailedTurnRollbackKeepsCompaction(t *testing.T) {
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want 1 fallback run", got)
}
if plannerSess.RewriteVersion() == 0 {
t.Fatal("test setup: planner compaction did not fire, the rewrite-aware rollback is not exercised")
// Canonical transcript is never rewrite-compacted.
if plannerSess.RewriteVersion() != 0 {
t.Fatalf("canonical rewrite version = %d, want 0", plannerSess.RewriteVersion())
}
// Canonical history is restored/cleaned without a dangling user turn so the
// next plan can continue. Projection lives on the planner agent and is not
// wiped by snapshot rollback of Session.Messages alone.
msgs := plannerSess.Snapshot()
var hasSummary bool
for _, m := range msgs {
if isCompactionSummary(m) {
hasSummary = true
}
}
if !hasSummary {
t.Fatal("rollback reverted the compaction: no compaction summary left in the planner session")
}
if last := msgs[len(msgs)-1]; last.Role == provider.RoleUser && !isCompactionSummary(last) {
t.Fatalf("planner session ends in a plain user message after rollback: %q", last.Content)
}
+10 -10
View File
@@ -1148,8 +1148,8 @@ func TestCompactionPrepareReplaceGuidance(t *testing.T) {
if !strings.Contains(sys, "EXTENSION GUIDANCE") {
t.Fatalf("summarizer system prompt missing the replaced guidance:\n%.200q", sys)
}
if sc := sessionContents(a.Session()); !strings.Contains(sc, "SUMMARY TEXT") {
t.Fatalf("session missing the summary:\n%.200q", sc)
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "SUMMARY TEXT") {
t.Fatalf("projection missing the summary:\n%.200q", sc)
}
if n := client.notifyCountFor(protocol.EventCompactionPrepare); n != 1 {
t.Fatalf("compaction.prepare events = %d, want 1", n)
@@ -1225,8 +1225,8 @@ func TestCompactionPrepareFailurePolicy(t *testing.T) {
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
if sc := sessionContents(a.Session()); !strings.Contains(sc, "SUMMARY TEXT") {
t.Fatalf("session missing the summary:\n%.200q", sc)
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "SUMMARY TEXT") {
t.Fatalf("projection missing the summary:\n%.200q", sc)
}
if !warns.contains("skipping this optional extension") {
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
@@ -1253,9 +1253,9 @@ func TestCompactionCompleteReplace(t *testing.T) {
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
sc := sessionContents(a.Session())
sc := joinContents(visibleContext(a))
if !strings.Contains(sc, "EXTENSION SUMMARY") {
t.Fatalf("session missing the replaced summary:\n%.200q", sc)
t.Fatalf("projection missing the replaced summary:\n%.200q", sc)
}
if strings.Contains(sc, "SUMMARY TEXT") {
t.Fatalf("session leaked the original summary:\n%.200q", sc)
@@ -1731,8 +1731,8 @@ func TestCompactionCompleteSlotOwnerConsulted(t *testing.T) {
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
if sc := sessionContents(a.Session()); !strings.Contains(sc, "OWNER SUMMARY") {
t.Fatalf("session missing the owner's summary:\n%.200q", sc)
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "OWNER SUMMARY") {
t.Fatalf("projection missing the owner's summary:\n%.200q", sc)
}
}
@@ -1765,9 +1765,9 @@ func TestCompactionCompleteSlotOwnerFinalSayAfterChain(t *testing.T) {
if calls != 2 {
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
}
sc := sessionContents(a.Session())
sc := joinContents(visibleContext(a))
if !strings.Contains(sc, "OWNER SUMMARY") || strings.Contains(sc, "CHAIN SUMMARY") {
t.Fatalf("session = %.200q, want the strategy ruling persisted", sc)
t.Fatalf("projection = %.200q, want the strategy ruling persisted", sc)
}
}
+293
View File
@@ -0,0 +1,293 @@
package agent
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// ErrCompactionRequired is returned when the prompt exceeds the provider limit
// and compaction could not produce a usable projection. Callers may retry.
var ErrCompactionRequired = errors.New("context exceeds provider limit and compaction failed")
// contextPreflight may compact into a projection under pressure; never rewrites history.
func (a *Agent) contextPreflight(ctx context.Context, trigger string) error {
if a == nil || a.session == nil || a.contextWindow <= 0 {
return nil
}
msgs, version := a.session.snapshotMessagesVersion()
cacheKey := a.currentPromptCacheKey()
est := estimateMessagesTokens(provider.ModelMessages(msgs))
_, _, high := a.compactThresholds()
force := max(int(float64(a.contextWindow)*a.compactForceRatio), high)
// Prefer an existing valid projection when it still covers the pressure.
if st := a.compactionState; projectionValid(st, msgs, version, cacheKey) {
visible := modelVisibleFromProjection(st.Projection, msgs)
projEst := estimateMessagesTokens(provider.ModelMessages(visible))
if projEst < high || projEst < est {
return nil
}
}
if est < high {
return nil
}
// Prefer a free prune/snip projection before a paid summarize call.
pruned, pst := a.applyToolResultMaintenanceView(msgs, toolResultPrune)
if pst.Results > 0 {
if err := a.installPruneProjection(pruned, pst); err == nil {
projEst := estimateMessagesTokens(provider.ModelMessages(pruned))
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"pruned %d stale tool results (~%d tokens est.) before request", pst.Results, est-projEst)})
if projEst < high {
return nil
}
est = projEst
}
}
forceCompact := est >= force || trigger == CompactionTriggerManual || trigger == CompactionTriggerOverflow
outcome, err := a.compactToProjection(ctx, trigger, "", forceCompact)
if err != nil {
if est >= force {
return fmt.Errorf("%w", errors.Join(ErrCompactionRequired, err))
}
a.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelInfo,
Text: "Context cleanup deferred; continuing with full history.",
Detail: fmt.Sprintf("compaction failed under soft threshold: %v", err),
})
return nil
}
// Force + Noop: refuse outside a tool loop; mid-turn is deferred.
if forceCompact && outcome == CompactionNoop {
if a.activeTurnStart(msgs) < 0 {
return fmt.Errorf("%w: no foldable region under force threshold", ErrCompactionRequired)
}
a.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelInfo,
Text: "Context cleanup deferred until the current tool turn finishes.",
Detail: "force threshold reached but the active turn cannot be folded yet",
})
}
return nil
}
// modelVisibleMessages returns the provider-bound message list: a valid
// projection plus any post-projection appends, otherwise the full canonical
// transcript. LocalOnly stripping still happens in prepareSamplingRequest.
func (a *Agent) modelVisibleMessages() []provider.Message {
if a == nil || a.session == nil {
return nil
}
msgs, version := a.session.snapshotMessagesVersion()
if st := a.compactionState; projectionValid(st, msgs, version, a.currentPromptCacheKey()) {
if visible := modelVisibleFromProjection(st.Projection, msgs); len(visible) > 0 {
return visible
}
}
return msgs
}
// currentPromptCacheKey is the lineage key for the bound session + model.
func (a *Agent) currentPromptCacheKey() string {
if a == nil {
return ""
}
return promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef)
}
// InvalidateProjection drops the in-memory and on-disk projection after
// lineage-changing operations (rewind, branch, fork, system/model change).
func (a *Agent) InvalidateProjection() {
if a == nil {
return
}
a.compactionState = CompactionState{}
if a.sessionPath != "" {
if err := RemoveCompactionState(a.sessionPath); err != nil {
slog.Warn("agent: remove context projection", "err", err)
}
}
}
// LoadProjectionSidecar loads the context sidecar into the agent. Corrupt or
// incompatible state is dropped so the next request rebuilds from canonical.
// Sidecars whose PromptCacheKey does not match the current agent lineage are
// discarded without deleting the file (another model may still own it).
func (a *Agent) LoadProjectionSidecar(sessionPath string) {
if a == nil {
return
}
a.sessionPath = sessionPath
if sessionPath == "" {
a.compactionState = CompactionState{}
return
}
st, ok, err := LoadCompactionState(sessionPath)
if err != nil {
slog.Warn("agent: load context projection", "err", err)
_ = RemoveCompactionState(sessionPath)
a.compactionState = CompactionState{}
return
}
if !ok {
a.compactionState = CompactionState{}
return
}
// Fail closed: known lineage requires an exact stored key (including
// rejecting blank keys on early sidecars written before this field).
if key := a.currentPromptCacheKey(); key != "" && st.PromptCacheKey != key {
a.compactionState = CompactionState{}
return
}
if st.Projection.CoveredPrefixHash == "" {
a.compactionState = CompactionState{}
return
}
a.compactionState = st
}
// BindSessionPath rebinds projection persistence to path. When loadSidecar is
// true the existing sidecar is loaded (resume/switch); otherwise in-memory
// projection is cleared without deleting another session's sidecar file.
func (a *Agent) BindSessionPath(path string, loadSidecar bool) {
if a == nil {
return
}
if loadSidecar {
a.LoadProjectionSidecar(path)
return
}
a.sessionPath = path
a.compactionState = CompactionState{}
a.cacheState = CacheStateUnknown
}
// SetSessionPath binds the transcript path used for projection persistence.
func (a *Agent) SetSessionPath(path string) {
if a == nil {
return
}
a.sessionPath = path
}
// SessionPath returns the bound transcript path.
func (a *Agent) SessionPath() string {
if a == nil {
return ""
}
return a.sessionPath
}
// SetCacheState records the resume-time cache estimate without rewriting history.
func (a *Agent) SetCacheState(state string) {
if a == nil {
return
}
switch state {
case CacheStateWarm, CacheStateCold, CacheStateUnknown:
default:
state = CacheStateUnknown
}
a.cacheState = state
if a.compactionState.SchemaVersion == 0 && len(a.compactionState.Projection.Messages) == 0 {
a.compactionState.SchemaVersion = compactionStateSchemaV1
}
a.compactionState.LastCacheState = state
a.compactionState.UpdatedAt = time.Now().UTC()
}
// CacheState returns the last estimated cache warm/cold/unknown label.
func (a *Agent) CacheState() string {
if a == nil || a.cacheState == "" {
return CacheStateUnknown
}
return a.cacheState
}
// persistCompactionState writes the sidecar when a session path is bound.
func (a *Agent) persistCompactionState() error {
if a == nil || a.sessionPath == "" {
return nil
}
return SaveCompactionState(a.sessionPath, a.compactionState)
}
// applyToolResultMaintenanceView returns a copy of msgs with stale tool results
// snipped or pruned. The canonical transcript is never modified.
func (a *Agent) applyToolResultMaintenanceView(msgs []provider.Message, mode toolResultMaintenanceMode) ([]provider.Message, PruneStats) {
var st PruneStats
if a.contextWindow <= 0 || len(msgs) == 0 {
return msgs, st
}
head, start, ok := a.planCompaction(msgs, 1)
if !ok {
if mode != toolResultPrune {
return msgs, st
}
head = 1
start = len(msgs) - a.recentKeep
if start < head {
return msgs, st
}
}
next := append([]provider.Message(nil), msgs...)
changed := false
for i := head; i < start; i++ {
m := next[i]
if !shouldMaintainToolResult(m, mode) {
continue
}
if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) {
continue
}
replacement := rewriteToolResult(m, mode, "projection-view", a.snipStrategyFor(m.Name))
if replacement == m.Content {
continue
}
st.SavedChars += len(m.Content) - len(replacement)
m.Content = replacement
next[i] = m
st.Results++
changed = true
}
if !changed {
return msgs, st
}
return next, st
}
// installProjection replaces the agent's projection and optionally persists it.
// On persist failure the in-memory state is rolled back so half-written state
// cannot be used for subsequent requests.
func (a *Agent) installProjection(st CompactionState) error {
prev := a.compactionState
a.compactionState = st
if err := a.persistCompactionState(); err != nil {
a.compactionState = prev
return err
}
return nil
}
// promptCacheKey builds a stable lineage key for session + model identity.
// It deliberately excludes message counts, timestamps, and projection hashes.
func promptCacheKey(workspaceID, sessionLineage, modelRef string) string {
parts := []string{
strings.TrimSpace(workspaceID),
strings.TrimSpace(sessionLineage),
strings.TrimSpace(modelRef),
}
return strings.Join(parts, "|")
}
+400
View File
@@ -0,0 +1,400 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// Context-projection schema versions. Readers accept any known version;
// writers always emit the current schema.
const (
compactionStateSchemaV1 = 1
)
// Cache state labels for resume/preflight telemetry. They never enter the
// provider-visible prompt.
const (
CacheStateWarm = "warm"
CacheStateCold = "cold"
CacheStateUnknown = "unknown"
)
// Compaction trigger labels.
const (
CompactionTriggerPressure = "pressure"
CompactionTriggerManual = "manual"
CompactionTriggerOverflow = "overflow"
CompactionTriggerSnip = "snip"
)
// Compaction mode labels.
const (
CompactionModeNative = "native"
CompactionModeSummarized = "summarized"
CompactionModeDegraded = "degraded"
CompactionModeSnip = "snip"
)
// ContextProjection is the model-visible view of a session. The canonical
// transcript in Session.Messages is never replaced by this structure.
type ContextProjection struct {
Messages []provider.Message `json:"messages"`
TranscriptVersion uint64 `json:"transcript_version"`
ProjectionVersion uint64 `json:"projection_version"`
// CoveredCount is len(canonical) when the projection was built. Model-visible
// context is projection.Messages + canonical[CoveredCount:].
CoveredCount int `json:"covered_count"`
// CoveredPrefixHash fingerprints provider-visible canonical[:CoveredCount]
// so append-only growth can be distinguished from prefix edits/rewrites.
CoveredPrefixHash string `json:"covered_prefix_hash,omitempty"`
SummaryHash string `json:"summary_hash,omitempty"`
SourceTokens int `json:"source_tokens,omitempty"`
ProjectionTokens int `json:"projection_tokens,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CompactionOutcome reports whether compactToProjection installed a projection.
type CompactionOutcome int
const (
// CompactionInstalled means a new (or replacement) projection was saved.
CompactionInstalled CompactionOutcome = iota
// CompactionNoop means no fold region / economics skip / empty fold after hooks.
CompactionNoop
)
// CompactionState is the session context sidecar payload.
type CompactionState struct {
SchemaVersion int `json:"schema_version"`
TranscriptVersion uint64 `json:"transcript_version"`
Projection ContextProjection `json:"projection"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
LastCacheState string `json:"last_cache_state,omitempty"`
LastTrigger string `json:"last_trigger,omitempty"`
LastMode string `json:"last_mode,omitempty"`
LastSourceTokens int `json:"last_source_tokens,omitempty"`
LastResultTokens int `json:"last_result_tokens,omitempty"`
LastCompactionCost float64 `json:"last_compaction_cost,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// CompactionTelemetry is the structured observability record for one
// compaction attempt. Sensitive transcript content is intentionally omitted.
type CompactionTelemetry struct {
Trigger string `json:"trigger"`
CacheState string `json:"cache_state"`
Mode string `json:"mode"`
Native bool `json:"native"`
SourceTokens int `json:"source_tokens"`
ProjectionTokens int `json:"projection_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
RequestCount int `json:"request_count"`
ProviderRequestID string `json:"provider_request_id,omitempty"`
Error string `json:"error,omitempty"`
}
// ContextStatePath returns the projection sidecar path for a session transcript.
func ContextStatePath(sessionPath string) string {
return store.SessionContext(sessionPath)
}
// LoadCompactionState reads the context sidecar. Missing files return ok=false.
// Corrupt or unsupported schema returns an error so callers can drop and rebuild.
func LoadCompactionState(sessionPath string) (CompactionState, bool, error) {
path := ContextStatePath(sessionPath)
if path == "" {
return CompactionState{}, false, nil
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return CompactionState{}, false, nil
}
return CompactionState{}, false, err
}
var st CompactionState
if err := json.Unmarshal(b, &st); err != nil {
return CompactionState{}, false, fmt.Errorf("decode context state %s: %w", path, err)
}
if st.SchemaVersion != 0 && st.SchemaVersion != compactionStateSchemaV1 {
return CompactionState{}, false, fmt.Errorf("unsupported context schema version %d", st.SchemaVersion)
}
if st.SchemaVersion == 0 {
st.SchemaVersion = compactionStateSchemaV1
}
return st, true, nil
}
// SaveCompactionState writes the sidecar via temp file + atomic rename.
func SaveCompactionState(sessionPath string, st CompactionState) error {
path := ContextStatePath(sessionPath)
if path == "" {
return fmt.Errorf("empty session path")
}
st.SchemaVersion = compactionStateSchemaV1
if st.UpdatedAt.IsZero() {
st.UpdatedAt = time.Now().UTC()
}
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
cleanup = false
return nil
}
// RemoveCompactionState deletes a corrupt or invalidated projection sidecar.
func RemoveCompactionState(sessionPath string) error {
path := ContextStatePath(sessionPath)
if path == "" {
return nil
}
err := os.Remove(path)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// summaryContentHash fingerprints a compaction summary for projection metadata.
func summaryContentHash(summary string) string {
if summary == "" {
return ""
}
sum := sha256.Sum256([]byte(summary))
return hex.EncodeToString(sum[:16])
}
// coveredPrefixHash fingerprints the provider-visible prefix of msgs[:n].
// LocalOnly and transport-only fields are stripped via ModelMessages; every
// remaining provider-visible field is included so edits to images, signed
// reasoning, Responses items, or thought signatures invalidate the projection.
func coveredPrefixHash(msgs []provider.Message, n int) string {
if n <= 0 || n > len(msgs) {
return ""
}
return providerVisibleFingerprint(provider.ModelMessages(msgs[:n]))
}
// providerVisibleFingerprint is the stable hash of fields that reach a provider.
func providerVisibleFingerprint(msgs []provider.Message) string {
type wireCall struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"args,omitempty"`
ThoughtSignature string `json:"ts,omitempty"`
}
type wireMsg struct {
Role string `json:"r"`
Content string `json:"c,omitempty"`
Images []string `json:"img,omitempty"`
ReasoningContent string `json:"rc,omitempty"`
ReasoningID string `json:"rid,omitempty"`
ReasoningStatus string `json:"rst,omitempty"`
ReasoningSignature string `json:"rsig,omitempty"`
ToolCallID string `json:"tid,omitempty"`
Name string `json:"n,omitempty"`
ToolCalls []wireCall `json:"tc,omitempty"`
ResponsesItems []json.RawMessage `json:"ri,omitempty"`
}
wire := make([]wireMsg, 0, len(msgs))
for _, m := range msgs {
wm := wireMsg{
Role: string(m.Role),
Content: m.Content,
Images: append([]string(nil), m.Images...),
ReasoningContent: m.ReasoningContent,
ReasoningID: m.ReasoningID,
ReasoningStatus: m.ReasoningStatus,
ReasoningSignature: m.ReasoningSignature,
ToolCallID: m.ToolCallID,
Name: m.Name,
}
for _, tc := range m.ToolCalls {
wm.ToolCalls = append(wm.ToolCalls, wireCall{
ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments, ThoughtSignature: tc.ThoughtSignature,
})
}
if len(m.ResponsesItems) > 0 {
wm.ResponsesItems = make([]json.RawMessage, len(m.ResponsesItems))
for i, item := range m.ResponsesItems {
wm.ResponsesItems[i] = append(json.RawMessage(nil), item...)
}
}
wire = append(wire, wm)
}
b, err := json.Marshal(wire)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:16])
}
// projectionValid reports whether st can be reused for the current transcript
// and provider/model lineage. Fail closed: missing CoveredPrefixHash or a blank
// sidecar PromptCacheKey when the current lineage key is known forces rebuild.
func projectionValid(st CompactionState, msgs []provider.Message, transcriptVersion uint64, cacheKey string) bool {
if len(st.Projection.Messages) == 0 {
return false
}
n := st.Projection.CoveredCount
if n <= 0 || n > len(msgs) {
return false
}
// Current lineage known: stored key must be present and equal.
if cacheKey != "" {
if st.PromptCacheKey == "" || st.PromptCacheKey != cacheKey {
return false
}
}
// Prefix hash is required; legacy sidecars without it are rebuilt.
if st.Projection.CoveredPrefixHash == "" {
return false
}
if coveredPrefixHash(msgs, n) != st.Projection.CoveredPrefixHash {
return false
}
if st.TranscriptVersion == transcriptVersion || st.Projection.TranscriptVersion == transcriptVersion {
return true
}
// Append-only growth with a verified covered prefix.
return n < len(msgs)
}
// modelVisibleFromProjection splices the projection with any messages appended
// after it was built. LocalOnly messages stay excluded via ModelMessages later.
func modelVisibleFromProjection(proj ContextProjection, canonical []provider.Message) []provider.Message {
if len(proj.Messages) == 0 {
return nil
}
out := append([]provider.Message(nil), proj.Messages...)
if proj.CoveredCount >= 0 && proj.CoveredCount < len(canonical) {
out = append(out, canonical[proj.CoveredCount:]...)
}
return out
}
// coalesceProjectionUserRuns keeps provider-visible projections compatible
// with providers that require strict user/assistant alternation. Compaction
// inserts its digest as a user message, and the retained tail can begin with a
// user message; merging the run in the projection preserves the digest prefix
// while leaving the canonical transcript untouched.
func coalesceProjectionUserRuns(msgs []provider.Message) []provider.Message {
if len(msgs) < 2 {
return msgs
}
out := make([]provider.Message, 0, len(msgs))
for _, msg := range msgs {
if len(out) == 0 || msg.Role != provider.RoleUser || out[len(out)-1].Role != provider.RoleUser {
clone := msg
clone.Images = append([]string(nil), msg.Images...)
clone.ToolCalls = append([]provider.ToolCall(nil), msg.ToolCalls...)
clone.ResponsesItems = append([]json.RawMessage(nil), msg.ResponsesItems...)
out = append(out, clone)
continue
}
prev := &out[len(out)-1]
if isCompactionSummary(msg) && !isCompactionSummary(*prev) {
prev.Content = strings.TrimRight(msg.Content, "\n") + "\n\n" + prev.Content
} else {
prev.Content = strings.TrimRight(prev.Content, "\n") + "\n\n" + msg.Content
}
prev.Images = append(prev.Images, msg.Images...)
prev.ToolCalls = append(prev.ToolCalls, msg.ToolCalls...)
prev.ResponsesItems = append(prev.ResponsesItems, msg.ResponsesItems...)
}
return out
}
// formatSummaryMessage builds the stable user-turn wrapper around a digest.
func formatSummaryMessage(summary string) provider.Message {
return provider.Message{
Role: provider.RoleUser,
Content: summaryTagOpen + "\n" +
"Summary of earlier conversation (older messages were compacted to save context):\n" +
summary + "\n" +
summaryTagClose,
}
}
// extractLatestSummary returns the body of the newest compaction summary in msgs.
func extractLatestSummary(msgs []provider.Message) string {
for i := range slices.Backward(msgs) {
if !isCompactionSummary(msgs[i]) {
continue
}
body := msgs[i].Content
body = strings.TrimPrefix(strings.TrimLeft(body, "\n "), summaryTagOpen)
body = strings.TrimSuffix(strings.TrimRight(body, "\n "), summaryTagClose)
body = strings.TrimPrefix(body, "\nSummary of earlier conversation (older messages were compacted to save context):\n")
return strings.TrimSpace(body)
}
return ""
}
// fixedEarlyUserTurns returns position-stable early small user turns after the
// system prompt. Unlike "latest N user turns", the set is fixed from the start
// of the transcript so later compressions do not reshuffle the cache prefix.
func (a *Agent) fixedEarlyUserTurns(msgs []provider.Message, head int) []provider.Message {
const maxEarly = 3
var early []provider.Message
for i := head; i < len(msgs) && len(early) < maxEarly; i++ {
m := msgs[i]
if m.LocalOnly || m.Role != provider.RoleUser || isCompactionSummary(m) {
continue
}
if !a.fixedPinnableUserTurn(m) {
// Large early turns stay foldable; stop extending the fixed prefix
// once a non-pinnable user turn appears so positions stay stable.
break
}
early = append(early, m)
}
return early
}
+677
View File
@@ -0,0 +1,677 @@
package agent
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestCompactionStateAtomicSaveLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sess.jsonl")
st := CompactionState{
SchemaVersion: compactionStateSchemaV1,
TranscriptVersion: 3,
Projection: ContextProjection{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summary-body"},
},
TranscriptVersion: 3,
ProjectionVersion: 1,
CoveredCount: 10,
SummaryHash: summaryContentHash("summary-body"),
SourceTokens: 1000,
ProjectionTokens: 200,
},
PromptCacheKey: "ws|sess|model",
LastCacheState: CacheStateCold,
LastTrigger: CompactionTriggerPressure,
LastMode: CompactionModeSummarized,
}
if err := SaveCompactionState(path, st); err != nil {
t.Fatalf("save: %v", err)
}
got, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load: ok=%v err=%v", ok, err)
}
if got.TranscriptVersion != 3 || got.LastMode != CompactionModeSummarized {
t.Fatalf("loaded state = %+v", got)
}
if len(got.Projection.Messages) != 2 || got.Projection.CoveredCount != 10 {
t.Fatalf("projection = %+v", got.Projection)
}
}
func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) {
fp := &fakeProvider{reply: "GOAL: ship projection\nFACTS: keep path /tmp/x"}
sess := NewSession("sys")
// Build enough history that a fold is economical.
for i := range 12 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: "user turn " + strings.Repeat("x", 80) + " " + string(rune('A'+i%26))})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "assistant work " + strings.Repeat("y", 200)})
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c" + string(rune('0'+i%10)), Name: "read", Arguments: `{"path":"f"}`}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c" + string(rune('0'+i%10)), Name: "read", Content: strings.Repeat("tool-out-", 40)})
}
before := append([]provider.Message(nil), sess.Messages...)
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
a := New(fp, nil, sess, Options{
ContextWindow: 2000,
RecentKeep: 2,
ArchiveDir: dir,
SessionPath: sessionPath,
ModelRef: "test/model",
}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
after := sess.Snapshot()
if len(after) != len(before) {
t.Fatalf("canonical length changed: before=%d after=%d", len(before), len(after))
}
for i := range before {
if before[i].Content != after[i].Content || before[i].Role != after[i].Role {
t.Fatalf("canonical message %d changed", i)
}
}
if len(a.compactionState.Projection.Messages) == 0 {
t.Fatal("expected projection messages")
}
// Projection must be shorter than canonical.
if estimateMessagesTokens(a.compactionState.Projection.Messages) >= estimateMessagesTokens(before) {
t.Fatalf("projection did not shrink: proj=%d src=%d",
estimateMessagesTokens(a.compactionState.Projection.Messages),
estimateMessagesTokens(before))
}
// Sidecar must exist and reload.
st, ok, err := LoadCompactionState(sessionPath)
if err != nil || !ok {
t.Fatalf("reload sidecar: ok=%v err=%v", ok, err)
}
if st.LastMode != CompactionModeSummarized {
t.Fatalf("mode = %q, want summarized", st.LastMode)
}
// Model-visible must use projection.
visible := a.modelVisibleMessages()
if len(visible) == len(before) {
t.Fatal("model-visible still full canonical")
}
// Summarizer must have been invoked with fold region (no tools schema).
if len(fp.got) == 0 {
t.Fatal("summarizer was not called")
}
}
func TestCompactFailureDoesNotWriteMechanicalMarker(t *testing.T) {
fp := &fakeProvider{streamErr: errors.New("boom")}
sess := NewSession("sys")
for range 10 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 100)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 200)})
}
before := append([]provider.Message(nil), sess.Messages...)
a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
err := a.CompactNow(context.Background(), "")
if err == nil {
t.Fatal("expected compaction error")
}
after := sess.Snapshot()
if len(after) != len(before) {
t.Fatalf("canonical changed on failure: %d → %d", len(before), len(after))
}
for _, m := range after {
if strings.Contains(m.Content, "summary was unavailable") || strings.Contains(m.Content, "folded here to free context") {
t.Fatalf("mechanical marker written into history: %q", m.Content)
}
}
if len(a.compactionState.Projection.Messages) != 0 {
t.Fatal("failed compaction installed a projection")
}
}
func TestFixedEarlyUserTurnsStableAcrossCompactions(t *testing.T) {
fp := &fakeProvider{reply: "digest-1"}
sess := NewSession("sys")
// 30 distinct user turns so a "latest N" strategy would reshuffle. The
// first four are large enough that usage-calibrated eligibility would reject
// them at 1 token/char, but the fixed fallback estimate accepts them.
for i := range 30 {
user := "unique-user-fact-" + strings.Repeat(string(rune('a'+i%26)), 20) + "-" + strings.Repeat("0", i%10+1)
if i < 4 {
user = "fixed-early-" + string(rune('a'+i)) + strings.Repeat("x", 1200)
}
sess.Add(provider.Message{Role: provider.RoleUser, Content: user})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("work-", 50) + string(rune('A'+i%26))})
}
dir := t.TempDir()
a := New(fp, nil, sess, Options{ContextWindow: 4000, RecentKeep: 2, ArchiveDir: dir, SessionPath: filepath.Join(dir, "s.jsonl")}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("compact1: %v", err)
}
firstPrefix := earlyUserPrefix(a.compactionState.Projection.Messages)
// Grow the session and compact again.
for i := range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: "later-fact-" + strings.Repeat("z", 30) + string(rune('0'+i))})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("more-", 60)})
}
// Simulate a projected request reporting a very different calibration from
// the pre-projection canonical estimate. This remains useful for tail sizing,
// but must not change which early turns define the stable prefix.
a.lastUsage.Store(&provider.Usage{PromptTokens: charsOfMessages(sess.Messages)})
if got := a.tokPerChar(); got < 0.9 || got > 1.1 {
t.Fatalf("test did not install the intended dynamic calibration: %f", got)
}
fp.reply = "digest-2"
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("compact2: %v", err)
}
secondPrefix := earlyUserPrefix(a.compactionState.Projection.Messages)
if firstPrefix != secondPrefix {
t.Fatalf("early user prefix drifted across compactions:\n1: %q\n2: %q", firstPrefix, secondPrefix)
}
// Exactly one summary in the projection (A1 rolling merge).
summaries := 0
for _, m := range a.compactionState.Projection.Messages {
if isCompactionSummary(m) {
summaries++
}
}
if summaries != 1 {
t.Fatalf("summaries in projection = %d, want 1", summaries)
}
}
func earlyUserPrefix(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
if m.Role == provider.RoleSystem {
continue
}
if isCompactionSummary(m) {
break
}
if m.Role == provider.RoleUser {
b.WriteString(m.Content)
b.WriteByte('\n')
}
}
return b.String()
}
func TestLocalOnlyExcludedFromCompactionRequest(t *testing.T) {
fp := &fakeProvider{reply: "ok"}
sess := NewSession("sys")
for range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
}
sess.Add(provider.Message{
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
Content: "secret local only", LocalOnly: true,
})
a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("compact: %v", err)
}
for _, m := range fp.got {
if strings.Contains(m.Content, "secret local only") {
t.Fatal("LocalOnly content reached summarizer")
}
}
for _, m := range a.compactionState.Projection.Messages {
if m.LocalOnly || strings.Contains(m.Content, "secret local only") {
t.Fatal("LocalOnly content entered projection")
}
}
}
func TestArchiveFailureLeavesNoProjection(t *testing.T) {
fp := &fakeProvider{reply: "digest"}
sess := NewSession("sys")
for range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
}
// Point archive at a file path so MkdirAll/Create fails.
badArchive := filepath.Join(t.TempDir(), "not-a-dir")
if err := writeFile(badArchive, []byte("x")); err != nil {
t.Fatal(err)
}
a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: badArchive}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err == nil {
t.Fatal("expected archive failure")
}
if len(a.compactionState.Projection.Messages) != 0 {
t.Fatal("half-finished projection after archive failure")
}
}
func writeFile(path string, b []byte) error {
return os.WriteFile(path, b, 0o644)
}
func visibleContext(a *Agent) []provider.Message {
if a == nil {
return nil
}
if msgs := a.compactionState.Projection.Messages; len(msgs) > 0 {
return msgs
}
if a.session != nil {
return a.session.Snapshot()
}
return nil
}
func hasCompactionSummary(msgs []provider.Message) bool {
return slices.ContainsFunc(msgs, isCompactionSummary)
}
func joinContents(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}
func TestCompactReplacesHistory(t *testing.T) {
prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
bigStep := strings.Repeat("important implementation detail ", 80)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task " + bigStep},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"},
{Role: provider.RoleAssistant, Content: "did a step"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
dir := t.TempDir()
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard)
beforeLen := len(sess.Messages)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical transcript is never rewritten by projection compaction.
if got := sess.RewriteVersion(); got != 0 {
t.Fatalf("rewrite version = %d, want 0 (canonical intact)", got)
}
if len(sess.Messages) != beforeLen {
t.Fatalf("canonical len changed: %d -> %d", beforeLen, len(sess.Messages))
}
proj := visibleContext(a)
if !hasCompactionSummary(proj) {
t.Fatalf("projection missing summary: %+v", proj)
}
if proj[0].Role != provider.RoleSystem {
t.Errorf("message 0 = %s, want system", proj[0].Role)
}
// Tail preserved in projection.
if proj[len(proj)-2].Content != "next" || proj[len(proj)-1].Content != "ok" {
t.Errorf("recent tail not preserved: %+v", proj[len(proj)-2:])
}
var foundSummary bool
for _, m := range proj {
if strings.Contains(m.Content, "Summary of earlier") && strings.Contains(m.Content, "do X") {
foundSummary = true
}
}
if !foundSummary {
t.Errorf("summary missing do X: %+v", proj)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("archive dir: entries=%d err=%v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("read archive: %v", err)
}
if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines < 1 {
t.Errorf("archived %d lines, want >=1:\n%s", lines, data)
}
if !strings.HasSuffix(entries[0].Name(), ".jsonl") {
t.Errorf("archive name = %q, want .jsonl", entries[0].Name())
}
}
func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) {
// Projection compaction must not rewrite history or install a mechanical
// marker when the summarizer fails. The error is returned so the caller
// can retry or report it.
prov := &fakeProvider{streamErr: errors.New("provider down")}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: "step one"},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: "step two"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
var got []event.Event
sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, sink)
before := append([]provider.Message(nil), sess.Messages...)
if err := a.compact(context.Background(), "manual", "", true); err == nil {
t.Fatal("compact should error when summarizer fails")
}
if len(sess.Messages) != len(before) {
t.Fatalf("canonical changed on summarizer failure: %d -> %d", len(before), len(sess.Messages))
}
for _, m := range sess.Messages {
if strings.Contains(m.Content, "summary was unavailable") {
t.Fatalf("mechanical marker written: %q", m.Content)
}
}
if len(a.compactionState.Projection.Messages) != 0 {
t.Fatal("failed compact installed a projection")
}
// CompactionDone with empty summary resolves the UI placeholder.
var done *event.Compaction
for i := range got {
if got[i].Kind == event.CompactionDone {
done = &got[i].Compaction
}
}
if done == nil {
t.Fatal("expected CompactionDone on abort")
}
}
func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) {
prov := &fakeProvider{reply: "- summary"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "a"},
{Role: provider.RoleAssistant, Content: "b"},
{Role: provider.RoleUser, Content: "c"},
{Role: provider.RoleAssistant, Content: "d"},
{Role: provider.RoleUser, Content: "e"},
{Role: provider.RoleAssistant, Content: "f"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
before := CaptureShape("sys", nil, sess.RewriteVersion())
if err := a.compact(context.Background(), "auto", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical rewriteVersion stays put; projection notes compact_auto for diagnostics.
if sess.RewriteVersion() != before.LogRewriteVersion {
t.Fatalf("canonical rewrite version changed: %d -> %d", before.LogRewriteVersion, sess.RewriteVersion())
}
if !hasCompactionSummary(visibleContext(a)) {
t.Fatal("expected projection summary")
}
reasons := sess.DrainContentRewriteReasons()
// Reasons alone attribute the prefix change; rewrite version need not bump.
after := CaptureShape("sys", nil, sess.RewriteVersion())
diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}, reasons)
if !diag.PrefixChanged {
t.Fatalf("diagnostics should report prefix change: %+v", diag)
}
if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "compact_auto" {
t.Fatalf("change reasons = %v, want [compact_auto]", diag.PrefixChangeReasons)
}
}
func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
big := strings.Repeat("work output ", 100)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
// Summarizer preserves the mid-session fact in the rolling digest.
a := New(&fakeProvider{reply: "Standing facts: always use pnpm not npm"}, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical retains every user turn; projection keeps fixed early turns
// and folds later small user turns into the single summary.
var pinnedFirst, keptMidCanonical bool
for _, m := range sess.Snapshot() {
if m.Role == provider.RoleUser && m.Content == "first task" {
pinnedFirst = true
}
if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
keptMidCanonical = true
}
}
if !pinnedFirst || !keptMidCanonical {
t.Fatalf("canonical lost user turns (first=%v mid=%v)", pinnedFirst, keptMidCanonical)
}
proj := visibleContext(a)
var projFirst, projMid bool
for _, m := range proj {
if isCompactionSummary(m) {
continue
}
if m.Role == provider.RoleUser && m.Content == "first task" {
projFirst = true
}
if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
projMid = true
}
}
if !projFirst {
t.Fatalf("fixed early user turn missing from projection: %+v", proj)
}
// The mid-session fact is still among the first few small user turns by
// position, so the fixed early window keeps it verbatim (stable prefix).
if !projMid {
t.Fatalf("early-position user fact missing from projection: %+v", proj)
}
if strings.Contains(joinContents(proj), big) {
t.Errorf("assistant/tool work was not folded out of projection")
}
}
func TestCompactKeepsPriorDigests(t *testing.T) {
// A1 rolling merge: prior digests enter the fold and the summarizer must
// carry durable facts forward into a single latest summary. The fake
// provider echoes the prior fact so we can assert the fold input included it.
priorDigest := summaryTagOpen + "\n## Standing facts\n- db is orion_prod_42\n" + summaryTagClose
big := strings.Repeat("work output ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big}, // breaks leading-summary contiguity
{Role: provider.RoleUser, Content: priorDigest},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
prov := &fakeProvider{reply: "Standing facts: db is orion_prod_42"}
a := New(prov, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical retains the prior digest; projection has exactly one summary.
var priorInCanonical bool
for _, m := range sess.Snapshot() {
if strings.Contains(m.Content, "orion_prod_42") {
priorInCanonical = true
}
}
if !priorInCanonical {
t.Fatal("canonical lost prior digest")
}
proj := visibleContext(a)
summaries := 0
for _, m := range proj {
if isCompactionSummary(m) {
summaries++
}
}
if summaries != 1 {
t.Fatalf("projection summaries = %d, want 1 (rolling merge)", summaries)
}
if !strings.Contains(joinContents(proj), "orion_prod_42") {
t.Fatalf("rolling summary lost prior fact: %+v", proj)
}
// Prior digest body was part of the fold sent to the summarizer.
if len(prov.got) < 2 || !strings.Contains(prov.got[1].Content, "orion_prod_42") {
t.Fatalf("prior digest not folded into summarizer input: %+v", prov.got)
}
}
func TestCompactKeepsErrorMessages(t *testing.T) {
prov := &fakeProvider{reply: "- normal work summarized"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: "continued"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical unchanged.
if sess.Messages[3].Content != "error: command failed" {
t.Fatalf("canonical error tool result changed: %+v", sess.Messages[3])
}
proj := visibleContext(a)
var keptErr bool
for i, m := range proj {
if m.Role == provider.RoleTool && m.Content == "error: command failed" {
keptErr = true
if i == 0 || proj[i-1].Role != provider.RoleAssistant || len(proj[i-1].ToolCalls) == 0 {
t.Fatalf("kept error lost its assistant tool call: %+v", proj)
}
}
}
if !keptErr {
t.Fatalf("error tool result not kept in projection: %+v", proj)
}
if strings.Contains(prov.got[1].Content, "error: command failed") {
t.Fatalf("kept error was still folded into summary input:\n%s", prov.got[1].Content)
}
}
func TestCompactKeepsUserMarkedMessages(t *testing.T) {
prov := &fakeProvider{reply: "- unmarked work summarized"}
marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleUser, Content: marked},
{Role: provider.RoleAssistant, Content: "worked"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
var keptCanonical, keptProj bool
for _, m := range sess.Messages {
if m.Content == marked {
keptCanonical = true
break
}
}
for _, m := range visibleContext(a) {
if m.Content == marked {
keptProj = true
break
}
}
if !keptCanonical {
t.Fatalf("marked message missing from canonical: %+v", sess.Messages)
}
if !keptProj {
t.Fatalf("marked message not kept in projection: %+v", visibleContext(a))
}
if strings.Contains(prov.got[1].Content, "exact requirement") {
t.Fatalf("marked message was still folded into summary input:\n%s", prov.got[1].Content)
}
}
func TestRunCompactsAfterFinalAnswer(t *testing.T) {
// A turn that ends with a final answer (no trailing tool batch) must still
// compact when the context is over the trigger; otherwise a large context
// carries into the next turn un-folded and overflows the model window.
big := strings.Repeat("old work ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleAssistant, Content: big},
}}
a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess,
Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if err := a.Run(context.Background(), "what's the status?"); err != nil {
t.Fatalf("run: %v", err)
}
if !hasCompactionSummary(visibleContext(a)) {
t.Fatalf("final-answer turn over the trigger did not install projection summary")
}
// Canonical rewrite version stays 0; projection carries the fold.
if got := sess.RewriteVersion(); got != 0 {
t.Fatalf("canonical rewrite version = %d, want 0", got)
}
}
func TestCompactFoldsSingleLargeMessage(t *testing.T) {
prov := &fakeProvider{reply: "- captured the large file contents"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("large output line\n", 500)},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
before := len(sess.Messages)
if err := a.compact(context.Background(), "auto", "", false); err != nil {
t.Fatalf("compact: %v", err)
}
if len(sess.Messages) != before {
t.Fatalf("canonical changed: %d -> %d", before, len(sess.Messages))
}
proj := visibleContext(a)
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "large file contents") {
t.Fatalf("single large message was not summarized into projection: %+v", proj)
}
if len(prov.got) == 0 || !strings.Contains(prov.got[1].Content, "large output line") {
t.Fatalf("summarizer did not receive the large message: %+v", prov.got)
}
}
+279
View File
@@ -0,0 +1,279 @@
package agent
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestProjectionValidRejectsEditedPrefix(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task-v1"},
{Role: provider.RoleAssistant, Content: "done"},
{Role: provider.RoleUser, Content: "next"},
}
st := CompactionState{
TranscriptVersion: 2,
PromptCacheKey: "ws|sess|model",
Projection: ContextProjection{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summary"},
},
TranscriptVersion: 2,
CoveredCount: 3,
CoveredPrefixHash: coveredPrefixHash(msgs, 3),
},
}
if !projectionValid(st, msgs, 2, "ws|sess|model") {
t.Fatal("expected valid projection for matching prefix")
}
// Append-only growth still valid.
grown := append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleAssistant, Content: "more"})
if !projectionValid(st, grown, 3, "ws|sess|model") {
t.Fatal("append-only growth should keep projection valid")
}
// Prefix edit invalidates.
edited := append([]provider.Message(nil), msgs...)
edited[1].Content = "task-EDITED"
if projectionValid(st, edited, 4, "ws|sess|model") {
t.Fatal("edited covered prefix must invalidate projection")
}
}
func TestProjectionValidRejectsCacheKeyMismatch(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
}
hash := coveredPrefixHash(msgs, 2)
st := CompactionState{
TranscriptVersion: 1,
PromptCacheKey: "ws|sess|model-a",
Projection: ContextProjection{
Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}},
CoveredCount: 2,
CoveredPrefixHash: hash,
TranscriptVersion: 1,
},
}
if projectionValid(st, msgs, 1, "ws|sess|model-b") {
t.Fatal("model/lineage key mismatch must invalidate projection")
}
if !projectionValid(st, msgs, 1, "ws|sess|model-a") {
t.Fatal("matching key should be valid")
}
// Fail closed: blank stored key is rejected when current key is known.
st.PromptCacheKey = ""
if projectionValid(st, msgs, 1, "ws|sess|model-a") {
t.Fatal("missing sidecar cache key must invalidate when lineage is known")
}
// Missing prefix hash is always rejected.
st.PromptCacheKey = "ws|sess|model-a"
st.Projection.CoveredPrefixHash = ""
if projectionValid(st, msgs, 1, "ws|sess|model-a") {
t.Fatal("missing CoveredPrefixHash must invalidate projection")
}
}
func TestCoveredPrefixHashIncludesProviderVisibleFields(t *testing.T) {
base := []provider.Message{{
Role: provider.RoleAssistant,
Content: "answer",
ReasoningContent: "think",
ReasoningID: "rid-1",
ReasoningStatus: "completed",
ReasoningSignature: "sig-1",
Images: []string{"data:image/png;base64,AAA"},
ToolCalls: []provider.ToolCall{{
ID: "c1", Name: "f", Arguments: `{}`, ThoughtSignature: "ts-1",
}},
ResponsesItems: []json.RawMessage{json.RawMessage(`{"type":"web_search_call"}`)},
}}
h1 := coveredPrefixHash(base, 1)
if h1 == "" {
t.Fatal("empty fingerprint")
}
// Each provider-visible field change must move the hash.
cases := []struct {
name string
mut func([]provider.Message)
}{
{"images", func(m []provider.Message) { m[0].Images = []string{"data:image/png;base64,BBB"} }},
{"reasoning_id", func(m []provider.Message) { m[0].ReasoningID = "rid-2" }},
{"reasoning_status", func(m []provider.Message) { m[0].ReasoningStatus = "in_progress" }},
{"reasoning_signature", func(m []provider.Message) { m[0].ReasoningSignature = "sig-2" }},
{"thought_signature", func(m []provider.Message) { m[0].ToolCalls[0].ThoughtSignature = "ts-2" }},
{"responses_items", func(m []provider.Message) {
m[0].ResponsesItems = []json.RawMessage{json.RawMessage(`{"type":"other"}`)}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mutated := []provider.Message{base[0]}
mutated[0].ToolCalls = append([]provider.ToolCall(nil), base[0].ToolCalls...)
mutated[0].Images = append([]string(nil), base[0].Images...)
mutated[0].ResponsesItems = append([]json.RawMessage(nil), base[0].ResponsesItems...)
tc.mut(mutated)
if coveredPrefixHash(mutated, 1) == h1 {
t.Fatalf("%s change did not alter coveredPrefixHash", tc.name)
}
})
}
}
func TestLoadProjectionSidecarDropsForeignCacheKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "s.jsonl")
msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
if err := SaveCompactionState(path, CompactionState{
SchemaVersion: compactionStateSchemaV1,
PromptCacheKey: "ws|s|other-model",
Projection: ContextProjection{
Messages: msgs,
CoveredCount: 1,
CoveredPrefixHash: coveredPrefixHash(msgs, 1),
},
}); err != nil {
t.Fatal(err)
}
a := New(nil, nil, NewSession("sys"), Options{
SessionPath: path,
WorkspaceID: "ws",
ModelRef: "this-model",
}, event.Discard)
// New() already called LoadProjectionSidecar; foreign key must be dropped.
if len(a.compactionState.Projection.Messages) != 0 {
t.Fatalf("foreign projection loaded: %+v", a.compactionState.Projection)
}
// Sidecar file remains for the other model.
if _, ok, err := LoadCompactionState(path); err != nil || !ok {
t.Fatalf("sidecar should remain on disk: ok=%v err=%v", ok, err)
}
}
func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) {
// Huge tool result is entirely in the recent tail → no fold region, but
// estimate exceeds force; preflight must refuse (not mid-turn).
huge := strings.Repeat("word ", 5000)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read", Content: huge},
}}
a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{
ContextWindow: 200,
CompactRatio: 0.5,
CompactForceRatio: 0.6,
RecentKeep: 2,
}, event.Discard)
err := a.contextPreflight(context.Background(), CompactionTriggerPressure)
if err == nil {
t.Fatal("expected ErrCompactionRequired when force threshold has no fold region")
}
if !errors.Is(err, ErrCompactionRequired) {
t.Fatalf("err = %v, want ErrCompactionRequired", err)
}
}
func TestSummarizeWithRetryMergesUsage(t *testing.T) {
fp := &retryUsageProvider{
failOnce: errors.New("transient"),
reply: "digest body",
usage1: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, RequestCount: 1},
usage2: &provider.Usage{PromptTokens: 11, CompletionTokens: 3, TotalTokens: 14, RequestCount: 1},
}
a := New(fp, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
summary, usage, err := a.summarizeWithRetry(context.Background(), []provider.Message{
{Role: provider.RoleUser, Content: "fold me"},
}, "")
if err != nil {
t.Fatalf("summarizeWithRetry: %v", err)
}
if summary != "digest body" {
t.Fatalf("summary = %q", summary)
}
if usage == nil {
t.Fatal("usage is nil")
}
// Both attempts contribute billable tokens and request count.
if usage.PromptTokens < 21 || usage.CompletionTokens < 5 {
t.Fatalf("merged usage under-counted: %+v", usage)
}
if usage.RequestCount < 2 {
t.Fatalf("RequestCount = %d, want >= 2 (both attempts)", usage.RequestCount)
}
}
// retryUsageProvider fails the first Stream, then returns reply + usage2.
type retryUsageProvider struct {
calls int
failOnce error
reply string
usage1 *provider.Usage
usage2 *provider.Usage
}
func (p *retryUsageProvider) Name() string { return "retry-usage" }
func (p *retryUsageProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
p.calls++
ch := make(chan provider.Chunk, 4)
if p.calls == 1 && p.failOnce != nil {
if p.usage1 != nil {
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage1}
}
ch <- provider.Chunk{Type: provider.ChunkError, Err: p.failOnce}
close(ch)
return ch, nil
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
if p.usage2 != nil {
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage2}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func TestCompactInstallsCoveredPrefixHash(t *testing.T) {
fp := &fakeProvider{reply: "digest"}
sess := NewSession("sys")
for range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
}
dir := t.TempDir()
path := filepath.Join(dir, "s.jsonl")
a := New(fp, tool.NewRegistry(), sess, Options{
ContextWindow: 2000,
RecentKeep: 2,
ArchiveDir: dir,
SessionPath: path,
WorkspaceID: "ws",
ModelRef: "m",
}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatal(err)
}
st := a.compactionState
if st.Projection.CoveredPrefixHash == "" {
t.Fatal("CoveredPrefixHash not set")
}
if st.PromptCacheKey != promptCacheKey("ws", BranchID(path), "m") {
t.Fatalf("PromptCacheKey = %q", st.PromptCacheKey)
}
msgs, ver := sess.snapshotMessagesVersion()
if !projectionValid(st, msgs, ver, st.PromptCacheKey) {
t.Fatal("fresh projection should validate")
}
}
+30 -22
View File
@@ -186,8 +186,19 @@ func TestMaybeCompactPruneAvoidsFold(t *testing.T) {
if prov.got != nil {
t.Fatal("summarizer was called although pruning cleared the trigger")
}
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, prunedMarker) {
t.Errorf("tool result not pruned: %.60q", got)
// Canonical stays intact; prune lands only in the projection view.
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "xxx") {
t.Errorf("canonical tool result rewritten: %.60q", got)
}
proj := visibleContext(a)
found := false
for _, m := range proj {
if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, prunedMarker) {
found = true
}
}
if !found {
t.Errorf("tool result not pruned in projection: %+v", proj)
}
}
@@ -201,8 +212,18 @@ func TestMaybeCompactSnipsAtSnipRatioWithoutFold(t *testing.T) {
if prov.got != nil {
t.Fatal("summarizer was called at snip ratio")
}
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, snippedMarker) {
t.Errorf("tool result not snipped at snip ratio: %.80q", got)
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "line") {
t.Errorf("canonical tool result rewritten at snip ratio: %.80q", got)
}
proj := visibleContext(a)
found := false
for _, m := range proj {
if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, snippedMarker) {
found = true
}
}
if !found {
t.Errorf("tool result not snipped in projection: %+v", proj)
}
}
@@ -225,15 +246,8 @@ func TestMaybeCompactPruneFallsThroughWhenStillOverThreshold(t *testing.T) {
if prov.got == nil {
t.Fatal("summarizer was not called although pruning still left prompt above compact threshold")
}
foundSummary := false
for _, m := range sess.Snapshot() {
if strings.Contains(m.Content, summaryTagOpen) {
foundSummary = true
break
}
}
if !foundSummary {
t.Fatal("summary compaction did not update the session")
if !hasCompactionSummary(visibleContext(a)) {
t.Fatal("summary compaction did not install a projection")
}
}
@@ -260,16 +274,10 @@ func TestMaybeCompactForceRatioStillFolds(t *testing.T) {
t.Fatal("force ratio crossed but summarizer never called")
}
if got := sess.Snapshot()[1].Content; got != "task" {
t.Errorf("first user turn not pinned verbatim: %.40q", got)
t.Errorf("first user turn not pinned verbatim in canonical: %.40q", got)
}
found := false
for _, m := range sess.Snapshot() {
if strings.Contains(m.Content, summaryTagOpen) {
found = true
}
}
if !found {
t.Error("no compaction summary in session after forced fold")
if !hasCompactionSummary(visibleContext(a)) {
t.Error("no compaction summary in projection after forced fold")
}
}
+92
View File
@@ -0,0 +1,92 @@
package agent
import (
"context"
"encoding/json"
"reasonix/internal/provider"
)
// samplingRequest is a once-prepared, frozen provider request for one model
// round. All stream retries replay this exact payload — no synthetic recovery
// messages, no schema reorder, no previous_response_id drift from failed attempts.
type samplingRequest struct {
req provider.Request
}
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
if err := a.contextPreflight(ctx, CompactionTriggerPressure); err != nil {
return samplingRequest{}, err
}
requestMessages := append([]provider.Message(nil), provider.ModelMessages(a.modelVisibleMessages())...)
for i := range requestMessages {
requestMessages[i].CreatedAt = 0
}
// context.prepare: extensions may rewrite the message copy feeding THIS
// request. The session log is never touched — the replacement is
// ephemeral, so the next request starts from the unmodified history and
requestMessages, err := a.interceptContextPrepare(ctx, requestMessages)
if err != nil {
return samplingRequest{}, err
}
req := provider.Request{
Messages: requestMessages,
Tools: a.tools.Schemas(),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
}
// provider.request: the fully assembled request gets one last ruling
// (revalidated by the payload registry) before it goes on the wire.
req, err = a.interceptProviderRequest(ctx, req)
if err != nil {
return samplingRequest{}, err
}
return samplingRequest{req: freezeProviderRequest(req)}, nil
}
// freezeProviderRequest deep-copies the provider-visible request surface so
// retries share identical messages, tools order, temperature, and format.
func freezeProviderRequest(req provider.Request) provider.Request {
out := req
if len(req.Messages) > 0 {
out.Messages = append([]provider.Message(nil), req.Messages...)
for i := range out.Messages {
if len(out.Messages[i].ToolCalls) > 0 {
out.Messages[i].ToolCalls = append([]provider.ToolCall(nil), out.Messages[i].ToolCalls...)
}
if len(out.Messages[i].Images) > 0 {
out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
}
if len(out.Messages[i].ResponsesItems) > 0 {
items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
for j, item := range out.Messages[i].ResponsesItems {
items[j] = append(json.RawMessage(nil), item...)
}
out.Messages[i].ResponsesItems = items
}
}
}
if len(req.Tools) > 0 {
out.Tools = make([]provider.ToolSchema, len(req.Tools))
for i, schema := range req.Tools {
out.Tools[i] = schema
if len(schema.Parameters) > 0 {
out.Tools[i].Parameters = append(json.RawMessage(nil), schema.Parameters...)
}
}
}
if req.Temperature != nil {
t := *req.Temperature
out.Temperature = &t
}
if req.ResponseFormat != nil {
rf := *req.ResponseFormat
out.ResponseFormat = &rf
}
return out
}
+28
View File
@@ -246,6 +246,19 @@ func (s *Session) DrainContentRewriteReasons() []string {
return reasons
}
// NoteContentRewrite queues a provider-visible prefix-change reason without
// mutating Messages. Projection installs use this so cache diagnostics still
// attribute the next request's miss to compaction while the canonical
// transcript stays intact.
func (s *Session) NoteContentRewrite(reason string) {
if s == nil || reason == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.pendingContentReasons = append(s.pendingContentReasons, reason)
}
// Snapshot returns a copy of the messages, safe to read from another goroutine
// while a turn appends. Frontends (History, Save) use it instead of touching the
// live slice.
@@ -330,6 +343,21 @@ func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
}
// snapshotMessagesVersion returns a copy of the messages with the transcript
// version, for projection validity checks that do not need rewriteVersion.
func (s *Session) snapshotMessagesVersion() ([]provider.Message, uint64) {
msgs, version, _ := s.snapshotWithVersion()
return msgs, version
}
// TranscriptVersion returns the current append/rewrite counter used by
// context-projection validity checks.
func (s *Session) TranscriptVersion() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.version
}
// RewriteVersion returns the current rewrite version.
func (s *Session) RewriteVersion() int {
s.mu.RLock()
+25 -44
View File
@@ -123,13 +123,11 @@ type Controller struct {
// memory owns the loaded memory snapshot, the pending turn-tail notes queue,
// and write serialization behind its own locks, off c.mu — so a memory-panel
// save never stalls an approval or status poll. See memory.go.
memory memoryManager
cleanup func()
responseLanguage string
reasoningLanguage string
// disableColdResumePrune skips stale-tool-result elision on cold resume.
// Zero value keeps the prune on (the cheaper default).
disableColdResumePrune bool
memory memoryManager
cleanup func()
responseLanguage string
reasoningLanguage string
disableColdResumePrune bool // legacy; rewrite elision removed, still gates cold notice
// testCacheColdAfter overrides cacheColdAfter() in tests. Zero uses the
// vendor-aware resolution from config.
testCacheColdAfter time.Duration
@@ -483,9 +481,8 @@ type Options struct {
// means no transient injection because the stable language policy already
// follows the conversation language.
ReasoningLanguage string
// DisableColdResumePrune skips the stale-tool-result elision that otherwise
// runs when a session resumes past the provider cache window. Zero value
// keeps the prune on (the cheaper default).
// DisableColdResumePrune suppresses the cold-resume cache-state notice.
// Resume never rewrites history regardless of this flag.
DisableColdResumePrune bool
// Shell is the interpreter user-invoked "!" commands run under, so /shell
// matches the agent's configured [tools.shell] choice. Zero value = auto.
@@ -2923,6 +2920,7 @@ func (c *Controller) NewSession() error {
}
c.setActiveJobSession(c.SessionPath())
c.executor.SetSession(agent.NewSession(c.systemPrompt))
c.bindExecutorProjection(c.SessionPath(), false)
if c.guardianSess != nil {
c.guardianSess.Reset()
}
@@ -3007,6 +3005,7 @@ func (c *Controller) ClearSession() error {
}
c.setActiveJobSession(c.SessionPath())
c.executor.SetSession(agent.NewSession(c.systemPrompt))
c.bindExecutorProjection(c.SessionPath(), false)
if c.guardianSess != nil {
c.guardianSess.Reset()
}
@@ -3222,11 +3221,14 @@ func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string
// See snapshotMu: the swap must not interleave with an in-flight save.
c.snapshotMu.Lock()
c.executor.SetSession(sess)
c.ResetPlannerSession()
c.mu.Lock()
c.sessionPath = newPath
c.guardianPath = guardian.PathFor(newPath)
c.mu.Unlock()
// New lineage: rebind sidecar path and clear any in-memory projection
// without deleting the parent session's .context.json.
c.bindExecutorProjection(newPath, false)
c.ResetPlannerSession()
c.setActiveJobSession(newPath)
c.rebindCheckpoints(newPath)
// A historical fork rewinds before later failures, so it starts with no
@@ -3306,11 +3308,12 @@ func (c *Controller) Branch(name string) (string, error) {
// See snapshotMu: the swap must not interleave with an in-flight save.
c.snapshotMu.Lock()
c.executor.SetSession(sess)
c.ResetPlannerSession()
c.mu.Lock()
c.sessionPath = newPath
c.guardianPath = guardian.PathFor(newPath)
c.mu.Unlock()
c.bindExecutorProjection(newPath, false)
c.ResetPlannerSession()
c.setActiveJobSession(newPath)
c.rebindCheckpoints(newPath)
if c.guardianSess != nil {
@@ -3369,11 +3372,12 @@ func (c *Controller) SwitchBranch(ref string) (agent.BranchInfo, error) {
if c.executor != nil {
c.executor.SetSession(loaded)
}
c.ResetPlannerSession()
c.mu.Lock()
c.sessionPath = match.Path
c.guardianPath = guardian.PathFor(match.Path)
c.mu.Unlock()
c.bindExecutorProjection(match.Path, true)
c.ResetPlannerSession()
c.setActiveJobSession(match.Path)
c.rebindCheckpoints(match.Path)
c.restoreTerminalGoalTodos(match.Path)
@@ -3497,11 +3501,12 @@ func (c *Controller) Resume(s *agent.Session, path string) {
if c.executor != nil {
c.executor.SetSession(s)
}
c.ResetPlannerSession()
c.mu.Lock()
c.sessionPath = path
c.guardianPath = guardian.PathFor(path)
c.mu.Unlock()
c.bindExecutorProjection(path, true)
c.ResetPlannerSession()
c.setActiveJobSession(path)
c.rebindCheckpoints(path)
if migPath, migData, migrated := c.goals.restoreFromState(path); migrated {
@@ -3598,36 +3603,6 @@ func (c *Controller) cacheColdAfter() time.Duration {
return entry.EffectiveCacheTTL()
}
// maybeColdResumePrune elides stale tool results when a resumed session has
// been idle past the provider's cache retention, then persists the pruned
// transcript so the saved file and the prompt stay in sync.
func (c *Controller) maybeColdResumePrune(path string) {
if c.disableColdResumePrune || c.executor == nil || path == "" {
return
}
// Idle time comes from branch meta only — every session the controller has
// ever snapshotted carries one. A meta-less transcript (e.g. a legacy import
// not yet saved) skips the prune until its first snapshot creates the meta.
m, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || m.UpdatedAt.IsZero() {
return
}
last := m.UpdatedAt
if time.Since(last) < c.cacheColdAfter() {
return
}
st, err := c.executor.PruneStaleToolResults()
if err != nil || st.Results == 0 {
return
}
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"resumed after %s idle (provider cache expired) — elided %d stale tool results to cheapen the cold restart",
time.Since(last).Round(time.Minute), st.Results)})
if err := c.SnapshotRewrite(); err != nil {
slog.Warn("controller: post-prune snapshot", "err", err)
}
}
// Snapshot writes the executor's conversation to the active session file. No-op
// when the executor is absent or the session has never been used (no user
// interaction). Returns errNoSessionPath when there IS content but no resolved
@@ -4057,6 +4032,9 @@ func (c *Controller) commitRecoveredSession(originalPath, reason string, info ag
c.sessionPath = info.Path
c.guardianPath = guardian.PathFor(info.Path)
c.mu.Unlock()
// Recovery branch is a new lineage path; do not keep writing the original
// session's projection sidecar.
c.bindExecutorProjection(info.Path, false)
c.setActiveJobSession(info.Path)
c.rebindCheckpoints(info.Path)
c.transplantInFlightTurnMarker(originalPath, info.Path)
@@ -4069,6 +4047,7 @@ func (c *Controller) adoptDiskSession(path string) bool {
return false
}
c.executor.SetSession(loaded)
c.bindExecutorProjection(path, true)
c.ResetPlannerSession()
c.rebindCheckpoints(path)
c.setActiveJobSession(path)
@@ -4604,6 +4583,8 @@ func (c *Controller) setSessionPath(p string, fresh bool) {
c.sessionPath = p
c.guardianPath = guardian.PathFor(p)
c.mu.Unlock()
// Fresh paths clear projection; rebinds keep/load the target sidecar.
c.bindExecutorProjection(p, !fresh)
c.setActiveJobSession(p)
c.rebindCheckpoints(p)
if fresh {
+47
View File
@@ -0,0 +1,47 @@
package control
import (
"fmt"
"log/slog"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
)
// bindExecutorProjection rebinds the agent's projection sidecar to path.
// loadSidecar=true loads an existing sidecar (resume/switch); false clears
// in-memory projection without deleting another session's .context.json.
func (c *Controller) bindExecutorProjection(path string, loadSidecar bool) {
if c == nil || c.executor == nil {
return
}
c.executor.BindSessionPath(path, loadSidecar)
}
// maybeColdResumePrune records warm/cold/unknown only; it never rewrites history.
func (c *Controller) maybeColdResumePrune(path string) {
if c.executor == nil || path == "" {
return
}
// Sidecar path is rebound in Resume; only refresh cache state here.
m, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || m.UpdatedAt.IsZero() {
c.executor.SetCacheState(agent.CacheStateUnknown)
slog.Info("controller: resume cache state", "path", path, "cache_state", agent.CacheStateUnknown)
return
}
last := m.UpdatedAt
state := agent.CacheStateWarm
if time.Since(last) >= c.cacheColdAfter() {
state = agent.CacheStateCold
}
c.executor.SetCacheState(state)
slog.Info("controller: resume cache state", "path", path, "cache_state", state, "idle", time.Since(last).Round(time.Minute).String())
if c.disableColdResumePrune || state != agent.CacheStateCold {
return
}
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"resumed after %s idle (provider cache likely expired) — full history kept; compaction deferred until context pressure",
time.Since(last).Round(time.Minute))})
}
+138
View File
@@ -0,0 +1,138 @@
package control
import (
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestNewSessionRebindsProjectionSidecarPath(t *testing.T) {
dir := t.TempDir()
oldPath := agent.NewSessionPath(dir, "old")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "old work"})
if err := sess.Save(oldPath); err != nil {
t.Fatal(err)
}
// Seed an old projection sidecar.
if err := agent.SaveCompactionState(oldPath, agent.CompactionState{
SchemaVersion: 1,
Projection: agent.ContextProjection{
Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}},
CoveredCount: 1,
},
PromptCacheKey: "ws|old|model",
}); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{
ContextWindow: 1000,
SessionPath: oldPath,
ModelRef: "test/model",
WorkspaceID: "ws",
}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.Resume(sess, oldPath)
if got := exec.SessionPath(); got != oldPath {
t.Fatalf("after resume SessionPath = %q, want %q", got, oldPath)
}
if err := c.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
newPath := c.SessionPath()
if newPath == "" || newPath == oldPath {
t.Fatalf("NewSession path = %q, want a distinct path", newPath)
}
if got := exec.SessionPath(); got != newPath {
t.Fatalf("executor SessionPath after NewSession = %q, want %q", got, newPath)
}
// Old sidecar must remain; new path must not inherit the old projection body.
if _, ok, err := agent.LoadCompactionState(oldPath); err != nil || !ok {
t.Fatalf("old sidecar should remain: ok=%v err=%v", ok, err)
}
if st, ok, err := agent.LoadCompactionState(newPath); err != nil {
t.Fatalf("load new sidecar: %v", err)
} else if ok && len(st.Projection.Messages) > 0 {
t.Fatalf("new session should not load old projection: %+v", st.Projection)
}
// Writing a projection after rotation must land on the new path.
if err := agent.SaveCompactionState(exec.SessionPath(), agent.CompactionState{
SchemaVersion: 1,
Projection: agent.ContextProjection{
Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}},
CoveredCount: 1,
CoveredPrefixHash: "x",
},
}); err != nil {
t.Fatal(err)
}
if _, ok, err := agent.LoadCompactionState(newPath); err != nil || !ok {
t.Fatalf("projection must be readable at new path: ok=%v err=%v", ok, err)
}
// Ensure we did not overwrite old sidecar content with the new key.
old, ok, err := agent.LoadCompactionState(oldPath)
if err != nil || !ok {
t.Fatalf("old sidecar: ok=%v err=%v", ok, err)
}
if old.PromptCacheKey != "ws|old|model" {
t.Fatalf("old sidecar polluted: %+v", old)
}
}
func TestClearSessionRebindsProjectionSidecarPath(t *testing.T) {
dir := t.TempDir()
path := agent.NewSessionPath(dir, "s")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{SessionPath: path}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.Resume(sess, path)
if err := c.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if got := exec.SessionPath(); got == path || got == "" {
t.Fatalf("ClearSession left executor path at %q", got)
}
if exec.SessionPath() != c.SessionPath() {
t.Fatalf("executor=%q controller=%q", exec.SessionPath(), c.SessionPath())
}
}
func TestBranchRebindsProjectionSidecarPath(t *testing.T) {
dir := t.TempDir()
path := agent.NewSessionPath(dir, "main")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
if _, err := agent.EnsureBranchMeta(path); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{SessionPath: path, ContextWindow: 1000}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.Resume(sess, path)
newPath, err := c.Branch("side")
if err != nil {
t.Fatalf("Branch: %v", err)
}
if newPath == path {
t.Fatal("Branch returned the same path")
}
if got := exec.SessionPath(); got != newPath {
t.Fatalf("executor SessionPath after Branch = %q, want %q", got, newPath)
}
if got := c.SessionPath(); got != newPath {
t.Fatalf("controller SessionPath after Branch = %q, want %q", got, newPath)
}
}
+40 -16
View File
@@ -11,7 +11,7 @@ import (
"reasonix/internal/provider"
)
func coldResumeFixture(t *testing.T, threshold time.Duration) (*agent.Session, string) {
func coldResumeFixture(t *testing.T, threshold time.Duration) (*agent.Session, string, *Controller) {
t.Helper()
dir := t.TempDir()
@@ -40,30 +40,30 @@ func coldResumeFixture(t *testing.T, threshold time.Duration) (*agent.Session, s
c.testCacheColdAfter = threshold
}
c.Resume(loaded, path)
return loaded, path
return loaded, path, c
}
func TestColdResumePrunesAndPersists(t *testing.T) {
loaded, path := coldResumeFixture(t, 0)
func TestColdResumeDoesNotRewriteOrNetwork(t *testing.T) {
loaded, path, c := coldResumeFixture(t, 0)
msgs := loaded.Snapshot()
if !strings.HasPrefix(msgs[3].Content, "[elided tool result") {
t.Fatalf("stale tool result not pruned on cold resume: %.60q", msgs[3].Content)
if !strings.HasPrefix(msgs[3].Content, "yyy") {
t.Fatalf("cold resume rewrote tool result: %.60q", msgs[3].Content)
}
re, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if !strings.HasPrefix(re.Messages[3].Content, "[elided tool result") {
t.Error("pruned transcript was not persisted")
if !strings.HasPrefix(re.Messages[3].Content, "yyy") {
t.Error("cold resume rewrote the saved transcript")
}
if re.Messages[3].ToolCallID != "1" {
t.Error("tool pairing lost in persisted transcript")
if c.executor.CacheState() != agent.CacheStateCold {
t.Fatalf("cache state = %q, want cold", c.executor.CacheState())
}
// No network: executor has nil provider; if cold resume called Compact it would panic/fail.
}
func TestColdResumeAfterClonedHistoryStaysInPlace(t *testing.T) {
dir := t.TempDir()
saved := agent.NewSession("old sys")
saved.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
@@ -96,15 +96,19 @@ func TestColdResumeAfterClonedHistoryStaysInPlace(t *testing.T) {
if got := c.SessionPath(); got != path {
t.Fatalf("SessionPath after cold resume = %q, want %q", got, path)
}
// Snapshot was not rewritten by cold resume; in-memory clone keeps new sys
// until an explicit save. Disk still has whatever was last saved unless
// SnapshotRewrite ran — cold path must not rewrite.
re, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := re.Messages[0].Content; got != "new sys" {
t.Fatalf("system prompt after cold resume = %q, want new sys", got)
if got := re.Messages[0].Content; got != "old sys" {
// Cold resume must not SnapshotRewrite the cloned in-memory system prompt.
t.Fatalf("system prompt on disk after cold resume = %q, want old sys (no rewrite)", got)
}
if !strings.HasPrefix(re.Messages[3].Content, "[elided tool result") {
t.Fatalf("stale tool result not pruned on cloned cold resume: %.60q", re.Messages[3].Content)
if !strings.HasPrefix(re.Messages[3].Content, "yyy") {
t.Fatalf("tool result rewrote on cold resume: %.60q", re.Messages[3].Content)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after cloned cold resume = %v err=%v, want none", matches, err)
@@ -112,7 +116,7 @@ func TestColdResumeAfterClonedHistoryStaysInPlace(t *testing.T) {
}
func TestWarmResumeLeavesHistoryAlone(t *testing.T) {
loaded, path := coldResumeFixture(t, 24*time.Hour)
loaded, path, c := coldResumeFixture(t, 24*time.Hour)
if got := loaded.Snapshot()[3].Content; !strings.HasPrefix(got, "yyy") {
t.Fatalf("warm resume rewrote history: %.60q", got)
@@ -124,4 +128,24 @@ func TestWarmResumeLeavesHistoryAlone(t *testing.T) {
if !strings.HasPrefix(re.Messages[3].Content, "yyy") {
t.Error("warm resume rewrote the saved transcript")
}
if c.executor.CacheState() != agent.CacheStateWarm {
t.Fatalf("cache state = %q, want warm", c.executor.CacheState())
}
}
func TestColdResumeUnderThresholdUsesCanonical(t *testing.T) {
// Small session well under context window: preflight must not compact.
loaded, path, c := coldResumeFixture(t, 0)
_ = path
if c.executor.CacheState() != agent.CacheStateCold {
t.Fatalf("want cold, got %s", c.executor.CacheState())
}
// Model-visible should still be full transcript (no projection yet).
visible := c.executor.Session().Snapshot()
if len(visible) != len(loaded.Snapshot()) {
t.Fatalf("visible/canonical mismatch without pressure")
}
if len(c.executor.CacheState()) == 0 {
t.Fatal("cache state empty")
}
}
+3
View File
@@ -49,6 +49,8 @@ func (a conversationApplier) ApplyConversationTruncate(boundary int, forward []b
}
}
s.Rewrite(msgs[:boundary], "rewind_truncate")
// Lineage changed: drop any projection built for the longer history.
c.executor.InvalidateProjection()
if err := c.SnapshotRewrite(); err != nil {
_ = a.RestoreConversation(forward)
return fmt.Errorf("persist conversation after rewind: %w", err)
@@ -66,6 +68,7 @@ func (a conversationApplier) RestoreConversation(forward []byte) error {
return err
}
c.executor.Session().Rewrite(msgs, "rewind_restore")
c.executor.InvalidateProjection()
if err := c.SnapshotRewrite(); err != nil {
return fmt.Errorf("restore conversation: %w", err)
}
+6 -5
View File
@@ -95,11 +95,12 @@ func NewSession(prov provider.Provider, readOnlyReg *tool.Registry, policyPrompt
RequireVisibleFinal: true, // each review must produce its own parseable verdict
// Use the shared context window so the guardian session can compact
// itself when it grows too large across many reviews.
ContextWindow: 100_000,
CompactRatio: 0.8,
SoftCompactRatio: 0.5,
ToolResultSnipRatio: 0.6,
CompactForceRatio: 0.9,
ContextWindow: 100_000,
CompactRatio: 0.8,
SoftCompactRatio: 0.5,
ToolResultSnipRatio: 0.6,
CompactForceRatio: 0.9,
StrictAlternatingRoles: true,
// Guardian's own sink drops everything — the audit line (emitTo) is the
// only user-visible output. Usage events are captured internally for
// per-review cost reporting.
+25 -9
View File
@@ -476,15 +476,6 @@ func TestGuardianSessionAlternatesAfterCompaction(t *testing.T) {
}
msgs := gs.sess.Snapshot()
var hasDigest bool
for _, m := range msgs {
if agent.IsCompactionSummary(m) {
hasDigest = true
}
}
if !hasDigest {
t.Fatal("test setup: guardian compaction did not fold anything, the digest adjacency is not exercised")
}
events := sink.guardianEvents()
if len(events) != compactEvery {
t.Fatalf("guardian events = %d, want %d", len(events), compactEvery)
@@ -498,4 +489,29 @@ func TestGuardianSessionAlternatesAfterCompaction(t *testing.T) {
t.Fatalf("guardian session has consecutive %s messages at indexes %d/%d of %d", msgs[i].Role, i-1, i, len(msgs))
}
}
// Projection compaction leaves the canonical transcript untouched. The next
// review must nevertheless send the compacted view, with the digest and tail
// coalesced so strict-alternation providers do not receive adjacent users.
parent.Add(provider.Message{Role: provider.RoleUser, Content: "post-compaction review"})
if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
t.Fatalf("post-compaction review = allow %v err %v, want allow nil", allow, err)
}
reqs := prov.requestsSnapshot()
if len(reqs) <= compactEvery {
t.Fatalf("provider requests = %d, want a request after projection compaction", len(reqs))
}
last := reqs[len(reqs)-1]
hasDigest := false
for i, m := range last.Messages {
if agent.IsCompactionSummary(m) {
hasDigest = true
}
if i > 0 && m.Role == provider.RoleUser && last.Messages[i-1].Role == provider.RoleUser {
t.Fatalf("post-compaction request carries consecutive user messages at index %d", i)
}
}
if !hasDigest {
t.Fatal("post-compaction request did not use the digest projection")
}
}
+28 -20
View File
@@ -51,10 +51,14 @@ const (
// defaultBaseURL is the first-party endpoint; config may override it (e.g. a
// gateway). Bedrock/Vertex use a different request shape and are out of scope.
defaultBaseURL = "https://api.anthropic.com"
// defaultMaxTokens is the output ceiling used when neither the provider config
// nor the request supplies one. Anthropic requires max_tokens, so unlike the
// optional OpenAI-compatible budget it cannot be omitted.
defaultMaxTokens = 32768
// defaultMaxTokens is the conservative output ceiling used when neither the
// provider config nor the request supplies one. Anthropic requires max_tokens,
// but support is model-specific, so native Anthropic and unknown compatible
// gateways must not inherit a universal 128K request.
defaultMaxTokens = provider.DefaultReasoningOutputTokens
// deepSeekDefaultMaxTokens is safe only for the official DeepSeek Anthropic-
// compatible endpoint, whose reasoning models support the higher ceiling.
deepSeekDefaultMaxTokens = provider.DefaultHighOutputTokens
)
func init() {
@@ -74,6 +78,22 @@ func New(cfg provider.Config) (provider.Provider, error) {
if baseURL == "" {
baseURL = defaultBaseURL
}
// Anthropic's API surface is at {root}/v1/messages, so c.baseURL stores
// the *root* -- without any trailing /v1. The setup wizard, however, lets
// users paste a full OpenAI-compatible URL (e.g.
// "https://proxy.example.com/v1") because that's what /models probes
// expect. Stripping the trailing /v1 here makes both forms land on the
// same endpoint without forcing users to remember Anthropic's quirky
// root-vs-versioned split. Without this, a user pasting
// "https://proxy.example.com/v1" would probe /v1/models successfully
// but get the chat client concatenating onto
// "https://proxy.example.com/v1/v1/messages" -- a 404.
root := strings.TrimRight(baseURL, "/")
root = strings.TrimSuffix(root, "/v1")
if root == "" {
root = defaultBaseURL
}
officialDeepSeek := openai.IsDeepSeek(root)
keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
keySource, _ := cfg.Extra["api_key_source"].(string)
thinking, _ := cfg.Extra["thinking"].(string)
@@ -89,26 +109,14 @@ func New(cfg provider.Config) (provider.Provider, error) {
// Messages requires max_tokens, so an optional-budget disable request
// falls back to the provider's stable mandatory default.
maxOutputTokens = defaultMaxTokens
if officialDeepSeek {
maxOutputTokens = deepSeekDefaultMaxTokens
}
}
httpClient, err := newHTTPClient(cfg)
if err != nil {
return nil, fmt.Errorf("anthropic: network: %w", err)
}
// Anthropic's API surface is at {root}/v1/messages, so c.baseURL stores
// the *root* — without any trailing /v1. The setup wizard, however, lets
// users paste a full OpenAI-compatible URL (e.g.
// "https://proxy.example.com/v1") because that's what /models probes
// expect. Stripping the trailing /v1 here makes both forms land on the
// same endpoint without forcing users to remember Anthropic's quirky
// root-vs-versioned split. Without this, a user pasting
// "https://proxy.example.com/v1" would probe /v1/models successfully
// but get the chat client concatenating onto
// "https://proxy.example.com/v1/v1/messages" — a 404.
root := strings.TrimRight(baseURL, "/")
root = strings.TrimSuffix(root, "/v1")
if root == "" {
root = defaultBaseURL
}
return &client{
name: name,
apiKey: cfg.APIKey,
@@ -117,7 +125,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
baseURL: root,
model: cfg.Model,
nativeAnthropic: strings.EqualFold(root, defaultBaseURL),
deepseek: openai.IsDeepSeek(root),
deepseek: officialDeepSeek,
thinking: thinking,
effort: effort,
vision: vision,
@@ -153,6 +153,35 @@ func TestConfiguredMaxOutputTokensRespectsMandatoryAnthropicFallback(t *testing.
}
}
func TestNewSelectsMaxOutputTokenDefaultByEndpoint(t *testing.T) {
for _, tc := range []struct {
name string
baseURL string
extra map[string]any
want int
}{
{name: "native anthropic", want: provider.DefaultReasoningOutputTokens},
{name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultReasoningOutputTokens},
{name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DefaultHighOutputTokens},
{name: "explicit override", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"max_output_tokens": 8192}, want: 8192},
} {
t.Run(tc.name, func(t *testing.T) {
p, err := New(provider.Config{
Name: "test",
BaseURL: tc.baseURL,
Model: "model",
Extra: tc.extra,
})
if err != nil {
t.Fatalf("New: %v", err)
}
if got := p.(*client).defaultMaxTokens; got != tc.want {
t.Fatalf("defaultMaxTokens = %d, want %d", got, tc.want)
}
})
}
}
func TestStreamAnnotatesIndexedToolSchemaError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
+74
View File
@@ -0,0 +1,74 @@
package provider
import (
"context"
"errors"
"time"
)
// ErrCompactionUnsupported is returned by a NativeCompactor when the provider
// or model has no native compaction endpoint. Agents treat it as a capability
// miss and fall back to ordinary summarization; it is not a request failure.
var ErrCompactionUnsupported = errors.New("provider: native compaction unsupported")
// NativeCompactor is an optional Provider capability. Providers that do not
// implement it are handled by the agent's summarize fallback.
type NativeCompactor interface {
Compact(ctx context.Context, req CompactionRequest) (CompactionResult, error)
}
// CompactionRequest is a provider-neutral native compaction call.
type CompactionRequest struct {
Messages []Message
Instructions string
MaxOutputTokens int
PromptCacheKey string
SessionID string
}
// CompactionResult is the provider-neutral outcome of a native compaction call.
// Projection and Summary must not both be empty.
type CompactionResult struct {
Projection []Message
Summary string
Usage *Usage
ProviderRequestID string
}
// Valid reports whether the result carries a usable projection and/or summary.
func (r CompactionResult) Valid() bool {
return len(r.Projection) > 0 || r.Summary != ""
}
// CompactionCapabilities describes model/provider compaction and cache limits.
// Defaults come only from an explicit vendor/model capability table; unknown
// compatible gateways must not inherit a large shared default.
type CompactionCapabilities struct {
NativeCompaction bool
MaxOutputTokens int
CompactionOutputTokens int
CacheTTL time.Duration
}
// CompactionCapabler is an optional Provider surface for capability lookup.
type CompactionCapabler interface {
CompactionCapabilities() CompactionCapabilities
}
// AsNativeCompactor returns p when it implements NativeCompactor.
func AsNativeCompactor(p Provider) (NativeCompactor, bool) {
if p == nil {
return nil, false
}
nc, ok := p.(NativeCompactor)
return nc, ok
}
// AsCompactionCapabler returns p when it implements CompactionCapabler.
func AsCompactionCapabler(p Provider) (CompactionCapabler, bool) {
if p == nil {
return nil, false
}
cc, ok := p.(CompactionCapabler)
return cc, ok
}
+80
View File
@@ -0,0 +1,80 @@
package provider_test
import (
"context"
"errors"
"testing"
"reasonix/internal/provider"
"reasonix/internal/provider/responses"
)
type unsupportedCompactor struct{}
func (unsupportedCompactor) Name() string { return "unsupported" }
func (unsupportedCompactor) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
return nil, errors.New("not implemented")
}
func (unsupportedCompactor) Compact(context.Context, provider.CompactionRequest) (provider.CompactionResult, error) {
return provider.CompactionResult{}, provider.ErrCompactionUnsupported
}
func TestNativeCompactorCapabilityMiss(t *testing.T) {
var p provider.Provider = unsupportedCompactor{}
nc, ok := provider.AsNativeCompactor(p)
if !ok {
t.Fatal("expected NativeCompactor")
}
_, err := nc.Compact(context.Background(), provider.CompactionRequest{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
PromptCacheKey: "ws|sess|model",
SessionID: "sess",
})
if !errors.Is(err, provider.ErrCompactionUnsupported) {
t.Fatalf("err = %v, want ErrCompactionUnsupported", err)
}
}
func TestResponsesCompactionCapabilities(t *testing.T) {
p := responses.New(responses.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash",
})
cc, ok := provider.AsCompactionCapabler(p)
if !ok {
t.Fatal("responses client must expose CompactionCapabler")
}
caps := cc.CompactionCapabilities()
if caps.NativeCompaction {
t.Fatal("DeepSeek Responses must not claim native compaction yet")
}
if caps.CompactionOutputTokens != 4096 {
t.Fatalf("CompactionOutputTokens = %d, want 4096", caps.CompactionOutputTokens)
}
if caps.MaxOutputTokens != provider.DefaultHighOutputTokens {
t.Fatalf("MaxOutputTokens = %d, want %d", caps.MaxOutputTokens, provider.DefaultHighOutputTokens)
}
unknown := responses.New(responses.Config{
Name: "compat",
BaseURL: "https://example.invalid/v1",
Model: "mystery",
})
ucaps := unknown.(provider.CompactionCapabler).CompactionCapabilities()
if ucaps.MaxOutputTokens != 0 || ucaps.CompactionOutputTokens != 0 {
t.Fatalf("unknown gateway must not inherit large defaults: %+v", ucaps)
}
}
func TestCompactionResultValid(t *testing.T) {
if (provider.CompactionResult{}).Valid() {
t.Fatal("empty result must be invalid")
}
if !(provider.CompactionResult{Summary: "x"}).Valid() {
t.Fatal("summary-only result must be valid")
}
if !(provider.CompactionResult{Projection: []provider.Message{{Role: provider.RoleUser, Content: "y"}}}).Valid() {
t.Fatal("projection-only result must be valid")
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
// output. Preserve an explicit user budget in either mode, but leave a
// thinking-disabled request uncapped unless the user configured one.
if maxOutputTokens == 0 && officialDeepSeek && thinkingType != "disabled" {
maxOutputTokens = provider.DefaultReasoningOutputTokens
maxOutputTokens = provider.DefaultHighOutputTokens // DeepSeek supports up to 384K; 128K is a safe default for reasoning
}
httpClient, err := newHTTPClient(cfg)
if err != nil {
+1 -1
View File
@@ -1245,7 +1245,7 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) {
}
deepseek := newClient(t, "https://api.deepseek.com", "deepseek-v4-flash", 0).buildRequest(provider.Request{})
if deepseek.MaxTokens != provider.DefaultReasoningOutputTokens || deepseek.MaxCompletionTokens != 0 {
if deepseek.MaxTokens != 131072 || deepseek.MaxCompletionTokens != 0 {
t.Fatalf("DeepSeek output budget = max_tokens %d, max_completion_tokens %d", deepseek.MaxTokens, deepseek.MaxCompletionTokens)
}
+8
View File
@@ -236,6 +236,14 @@ type ResponseFormat struct {
// inheriting this value merely because they implement an OpenAI-shaped wire.
const DefaultReasoningOutputTokens = 32 * 1024
// DefaultHighOutputTokens is the raised output budget for reasoning APIs whose
// documented contract safely accepts 128K-class ceilings (DeepSeek Responses
// API allows up to 384K; MiMo allows up to 131072). Long reasoning turns
// truncate under 32K, forcing many small write→test→fix iterations; a 128K
// budget lets the model finish in one pass. Kept in one place so the three
// protocols (Responses / Chat Completions / Anthropic) cannot drift apart.
const DefaultHighOutputTokens = 128 * 1024
// TemperaturePtr wraps v in a pointer so callers that explicitly want a
// specific temperature, including 0 for deterministic output, can distinguish
// that intent from "not set, use the provider default".
+30
View File
@@ -0,0 +1,30 @@
package responses
import (
"context"
"reasonix/internal/provider"
)
// CompactionCapabilities reports model-level compaction budgets from the
// vendor capability table. Unknown endpoints report zero budgets so agents
// do not inherit a large default merely for being OpenAI-compatible.
func (c *client) CompactionCapabilities() provider.CompactionCapabilities {
if c == nil {
return provider.CompactionCapabilities{}
}
return provider.CompactionCapabilities{
NativeCompaction: c.caps.nativeCompaction,
MaxOutputTokens: c.caps.defaultMaxOutputTokens,
CompactionOutputTokens: c.caps.compactionOutputTokens,
}
}
// Compact implements provider.NativeCompactor. Responses vendors do not yet
// expose a dedicated compact endpoint, so this always returns
// ErrCompactionUnsupported and the agent falls back to ordinary summarize.
func (c *client) Compact(ctx context.Context, req provider.CompactionRequest) (provider.CompactionResult, error) {
_ = ctx
_ = req
return provider.CompactionResult{}, provider.ErrCompactionUnsupported
}
+2 -1
View File
@@ -126,7 +126,8 @@ func New(cfg Config) provider.Provider {
vendor := DetectVendor(cfg.BaseURL)
cap := capabilitiesFor(vendor)
maxOutputTokens := cfg.MaxOutputTokens
// 默认输出预算从 vendor 表取(deepseek 32K / mimo 64K)——消除硬编码
// 默认输出预算从 vendor 表取(deepseek 128K / mimo 128K)——消除硬编码
// 常量分叉(review:responses.go 硬编码与 caps.defaultMaxOutputTokens
// 职责重叠)。条件保留:thinking-disabled 的 deepseek 请求不设自动
// 预算(与 openai.go 一致——服务端默认即可;测试断言该行为)。
@@ -105,8 +105,8 @@ func TestRequestUsesOnlySafeProviderOutputDefaults(t *testing.T) {
deepseek := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
deepseekBody, _, _ := deepseek.buildRequestBody(provider.Request{Messages: message})
if got := deepseekBody["max_output_tokens"]; got != provider.DefaultReasoningOutputTokens {
t.Fatalf("DeepSeek max_output_tokens = %#v, want %d", got, provider.DefaultReasoningOutputTokens)
if got := deepseekBody["max_output_tokens"]; got != 131072 {
t.Fatalf("DeepSeek max_output_tokens = %#v, want 131072", got)
}
for _, effort := range []string{"none", "disabled", "off", " NONE "} {
@@ -1126,21 +1126,21 @@ func TestReasoningMetaChunkEndToEnd(t *testing.T) {
}
// TestVendorTableMaxOutputTokens:默认输出预算完全由 vendor 表驱动——
// mimo 65536(长思考不截断)、deepseek 32K、unknown 不设。
// mimo 128000(长思考不截断)、deepseek 128K、unknown 不设。
func TestVendorTableMaxOutputTokens(t *testing.T) {
msg := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
// mimo:表默认 65536(思考模式不设会顶到服务端 32768 截断)
// mimo:表默认 128000(思考模式不设会顶到服务端 32768 截断)
mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"}).(*client)
body, _, _ := mimo.buildRequestBody(provider.Request{Messages: msg})
if got := body["max_output_tokens"]; got != 65536 {
t.Fatalf("mimo max_output_tokens = %#v, want 65536 (vendor table)", got)
if got := body["max_output_tokens"]; got != 128000 {
t.Fatalf("mimo max_output_tokens = %#v, want 128000 (vendor table)", got)
}
// mimo 思考禁用也设 65536(mimo 无 thinking-disabled 豁免——表无条件)
// mimo 思考禁用也设 128000(mimo 无 thinking-disabled 豁免——表无条件)
noThinking := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", Effort: "none"}).(*client)
nb, _, _ := noThinking.buildRequestBody(provider.Request{Messages: msg})
if nb["max_output_tokens"] != 65536 {
t.Fatalf("mimo thinking-disabled budget = %#v, want 65536", nb["max_output_tokens"])
if nb["max_output_tokens"] != 128000 {
t.Fatalf("mimo thinking-disabled budget = %#v, want 128000", nb["max_output_tokens"])
}
// deepseek 值来自表(非硬编码常量)
ds := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"}).(*client)
+18 -2
View File
@@ -58,6 +58,15 @@ type vendorCapabilities struct {
// answer survives long reasoning.
defaultMaxOutputTokens int
// compactionOutputTokens is the separate budget for native/summary
// compaction calls. Zero means "no dedicated compaction budget; fall
// back to ordinary summarize without inheriting a large default".
compactionOutputTokens int
// nativeCompaction marks vendors with a dedicated compact endpoint.
// When false, agents must use ordinary summarize fallback.
nativeCompaction bool
// summaryRequired marks vendors whose Responses API requires the
// `summary` list on input reasoning items (DashScope; without it the
// server rejects with "Invalid 'summary': summary is required..."). The
@@ -77,6 +86,8 @@ var vendorTable = map[string]vendorCapabilities{
singleSegmentReasoning: false,
ignoresTemperature: false,
summaryRequired: true,
// No native compact endpoint yet; summarize fallback only.
compactionOutputTokens: 8192,
},
"deepseek": {
stateless: true,
@@ -84,7 +95,10 @@ var vendorTable = map[string]vendorCapabilities{
toolCallReasoning: true,
singleSegmentReasoning: false,
ignoresTemperature: false,
defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens,
defaultMaxOutputTokens: provider.DefaultHighOutputTokens,
// Compaction summaries are short briefings; keep the budget separate
// from ordinary answer output so a summary call cannot inherit 32K.
compactionOutputTokens: 4096,
},
"mimo": {
stateless: true,
@@ -92,9 +106,11 @@ var vendorTable = map[string]vendorCapabilities{
toolCallReasoning: true,
singleSegmentReasoning: true,
ignoresTemperature: true,
defaultMaxOutputTokens: 65536,
defaultMaxOutputTokens: 128000,
compactionOutputTokens: 4096,
},
// "" (unknown OpenAI-compatible endpoint) → zero value = default behavior.
// Unknown gateways deliberately do NOT inherit a large max-output default.
}
// capabilitiesFor returns the wire capabilities for a detected vendor name.
+12
View File
@@ -38,6 +38,17 @@ func SessionRecoveryState(sessionPath string) string {
return sessionStem(sessionPath) + ".recovery.json"
}
// SessionContext is the context-projection / compaction-state sidecar
// (<id>.context.json). It holds the model-visible projection and cache
// telemetry; the primary .jsonl remains the canonical transcript.
func SessionContext(sessionPath string) string {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return ""
}
return sessionStem(sessionPath) + ".context.json"
}
// sessionStem strips the .jsonl suffix so a sidecar sits beside the session as
// <id>.<kind> rather than <id>.jsonl.<kind>.
func sessionStem(sessionPath string) string {
@@ -173,5 +184,6 @@ func SessionSidecarFiles(sessionPath string) []string {
SessionEventIndex(sessionPath),
SessionConflictLog(sessionPath),
SessionRecoveryState(sessionPath),
SessionContext(sessionPath),
}
}
+3
View File
@@ -22,6 +22,7 @@ func TestSessionSidecarLayout(t *testing.T) {
{"checkpoint", SessionCheckpointDir(p), "/home/u/.reasonix/sessions/abc.ckpt"},
{"jobs", SessionJobsDir(p), "/home/u/.reasonix/sessions/abc.jobs"},
{"cleanup-pending", SessionCleanupPending(p), "/home/u/.reasonix/sessions/abc.cleanup-pending.json"},
{"context", SessionContext(p), "/home/u/.reasonix/sessions/abc.context.json"},
}
for _, c := range cases {
if c.got != c.want {
@@ -47,6 +48,7 @@ func TestSessionSidecarEmptyPath(t *testing.T) {
{"checkpoint", SessionCheckpointDir},
{"jobs", SessionJobsDir},
{"cleanup-pending", SessionCleanupPending},
{"context", SessionContext},
} {
if got := fn.f(""); got != "" {
t.Errorf("%s(\"\") = %q, want empty", fn.name, got)
@@ -87,6 +89,7 @@ func TestSessionSidecarFiles(t *testing.T) {
"/home/u/.reasonix/sessions/abc.event-index.json",
"/home/u/.reasonix/sessions/abc.conflicts.jsonl",
"/home/u/.reasonix/sessions/abc.recovery.json",
"/home/u/.reasonix/sessions/abc.context.json",
}
if len(got) != len(want) {
t.Fatalf("SessionSidecarFiles = %v, want %v", got, want)
+18 -22
View File
@@ -2,12 +2,12 @@
"limits": {
"banner": 0,
"commented-code": 0,
"essay": 4111,
"file-size": 73726,
"essay": 4115,
"file-size": 73588,
"layering": 1,
"marker": 0,
"narrative": 66,
"test-file-size": 64896
"test-file-size": 64726
},
"files": {
"cmd/e2ebench/main.go": {
@@ -16,9 +16,6 @@
"cmd/e2ebench/mutation.go": {
"essay": 1
},
"cmd/e2ebench/swebench_run.go": {
"essay": 2
},
"cmd/reasonix-legacy-migrator/main.go": {
"essay": 1
},
@@ -301,8 +298,8 @@
"essay": 7
},
"internal/agent/agent.go": {
"essay": 128,
"file-size": 3270
"essay": 127,
"file-size": 3201
},
"internal/agent/ask.go": {
"essay": 4
@@ -326,20 +323,19 @@
"essay": 1
},
"internal/agent/compact.go": {
"essay": 19,
"file-size": 59
"essay": 27
},
"internal/agent/compact_test.go": {
"essay": 3,
"test-file-size": 202
"essay": 4,
"test-file-size": 8
},
"internal/agent/coordinator.go": {
"essay": 20,
"file-size": 292
},
"internal/agent/coordinator_test.go": {
"essay": 5,
"test-file-size": 1244
"essay": 3,
"test-file-size": 1239
},
"internal/agent/delivery_hardening_test.go": {
"essay": 5
@@ -784,7 +780,7 @@
},
"internal/config/config.go": {
"essay": 56,
"file-size": 1489,
"file-size": 1481,
"narrative": 2
},
"internal/config/credentials.go": {
@@ -874,7 +870,7 @@
},
"internal/control/controller.go": {
"essay": 180,
"file-size": 5367,
"file-size": 5348,
"narrative": 4
},
"internal/control/controller_test.go": {
@@ -1313,11 +1309,11 @@
},
"internal/provider/anthropic/anthropic.go": {
"essay": 36,
"file-size": 100
"file-size": 108
},
"internal/provider/anthropic/anthropic_test.go": {
"essay": 1,
"test-file-size": 221
"test-file-size": 250
},
"internal/provider/openai/host.go": {
"essay": 8
@@ -1334,12 +1330,12 @@
"essay": 7
},
"internal/provider/provider.go": {
"essay": 50,
"file-size": 345
"essay": 51,
"file-size": 353
},
"internal/provider/responses/responses.go": {
"essay": 26,
"file-size": 78
"essay": 25,
"file-size": 79
},
"internal/provider/responses/responses_test.go": {
"essay": 6,