mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "|")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user