Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// The shared harness decision surface adds a bounded startup stylesheet
// payload. The current base plus exact prompt identity and stale-card recovery
// measure 2496.4 KiB locally; retain the smallest bounded ceiling.
const rawInitialBudgetKiB = 2_496.5;
// The context truncation-rescue notice and its three locale strings measure
// 2496.6 KiB; retain the smallest bounded ceiling.
const rawInitialBudgetKiB = 2_496.7;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const messages: Partial<Record<DictKey, string>> = {
"context.maintenanceAppliedSummary": "已生成短视图",
"context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试",
"context.maintenanceFailedSummary": "摘要失败 · 已停重试",
"context.maintenanceTruncatedSummary": "已裁剪上下文视图",
"context.tokensValue": "{value} tokens",
"summary.detail": "摘要",
};
Expand All @@ -39,6 +40,9 @@ ok(blocked === "摘要未形成短视图 · 已停重试", `unexpected blocked n
const failed = formatContextMaintenanceNotice({ status: "failed" }, translate);
ok(failed === "摘要失败 · 已停重试", `unexpected failed notice: ${failed}`);

const truncated = formatContextMaintenanceNotice({ status: "applied", action: "truncate" }, translate);
ok(truncated === "已裁剪上下文视图", `unexpected truncated notice: ${truncated}`);

const contextPanelSource = readFileSync(new URL("../components/ContextPanel.tsx", import.meta.url), "utf8");
ok(
!contextPanelSource.includes('className="context-panel__maintenance"'),
Expand Down
8 changes: 5 additions & 3 deletions desktop/frontend/src/lib/contextMaintenanceTypes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Translator } from "./i18n";

export type ContextMaintenanceStatus = "planned" | "applied" | "noop" | "blocked" | "failed";
/** New writers only emit summary | noop. snip/prune/native are legacy restore-only. */
export type ContextMaintenanceAction = "summary" | "noop" | "snip" | "prune" | "native_tool_clear";
/** New writers emit summary | noop | truncate (the lossy ceiling rescue). snip/prune/native are legacy restore-only. */
export type ContextMaintenanceAction = "summary" | "noop" | "truncate" | "snip" | "prune" | "native_tool_clear";

export interface WireContextMaintenance {
status?: ContextMaintenanceStatus;
Expand Down Expand Up @@ -77,7 +77,9 @@ export interface ContextBudgetInfo {
export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Translator): string {
switch (m.status) {
case "applied":
return t("context.maintenanceAppliedSummary");
return m.action === "truncate"
? t("context.maintenanceTruncatedSummary")
: t("context.maintenanceAppliedSummary");
case "blocked":
return t("context.maintenanceBlockedSummary");
case "failed":
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3468,6 +3468,7 @@ export const en = {
"context.maintenanceAppliedSummary": "Built a short context view · history summarized",
"context.maintenanceBlockedSummary": "Context summary could not form a safe short view · auto-retry stopped",
"context.maintenanceFailedSummary": "Context summary failed · auto-retry stopped",
"context.maintenanceTruncatedSummary": "Trimmed the context view · oldest tool results and turns removed to fit the window",
"context.maintenanceActionSnip": "Tool result snip",
"context.maintenanceActionPrune": "Tool result prune",
"context.maintenanceActionNative": "Native tool clearing",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3554,6 +3554,7 @@ export const zhTW: Record<DictKey, string> = {
"context.maintenanceAppliedSummary": "已生成短視圖",
"context.maintenanceBlockedSummary": "摘要未形成短視圖 · 已停重試",
"context.maintenanceFailedSummary": "摘要失敗 · 已停重試",
"context.maintenanceTruncatedSummary": "已裁剪上下文視圖 · 移除最舊的工具結果與輪次以適配視窗",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3471,6 +3471,7 @@ export const zh: Record<DictKey, string> = {
"context.maintenanceAppliedSummary": "已生成短视图",
"context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试",
"context.maintenanceFailedSummary": "摘要失败 · 已停重试",
"context.maintenanceTruncatedSummary": "已裁剪上下文视图 · 移除最旧的工具结果与轮次以适配窗口",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
Expand Down
23 changes: 18 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,13 +287,26 @@ when the sole automatic threshold is crossed.
- The summary request replays the original system message, the selected message
prefix, and the ordinary request's tool schemas, then appends one final user
compaction instruction. This shape can reuse provider KV cache. Output is capped
at **8192 tokens**. A pressure run may make one additional convergence summary
(at most two successful summaries total); overflow makes at most one summary and
retries the original request at most once after projection-version progress.
at **8192 tokens**, and prefix planning keeps **5%** of the window (at least 256
tokens) below that cap as estimator headroom. A pressure run may make one
additional convergence summary (at most two successful summaries total);
overflow makes at most one summary and retries the original request at most
once after projection-version progress. An overflow rescue may also fold the
active turn's completed rounds, keeping its newest two rounds verbatim.
- Every summary reply, success or provider overflow, feeds its real prompt count
back into the estimator. When the provider rejects the summary request itself,
the fold is re-planned on the corrected estimate (at most twice), then sent once
as a bounded transcript (tool results cut to 2000 characters, no tool schemas);
a manual compact may then take the fragment path. A failed automatic attempt
backs off further attempts on the same turn until the view has grown by 5% of
the window since that attempt, which bounds the retries one turn can pay.
- A checkpoint must be strictly smaller than the replaced full request. Summary
timeout/error/empty/max-token results never produce a mechanical digest. Below
the hard ceiling the latest durable projection continues; at overflow or the
hard ceiling an insufficient prune returns `ErrCompactionRequired`.
the hard ceiling the latest durable projection continues. At overflow or the
hard ceiling, when no summary can form, a lossy `truncate` projection elides the
oldest tool results and then drops the oldest replay units behind an explicit
marker until the view fits under the trigger; `ErrCompactionRequired` is
returned only when even that cannot reclaim enough.
- Users inspect or change the threshold with
`reasonix config compact-ratio [--local] [VALUE]`. Project config overrides the
user-global value used by desktop and new CLI sessions. UI always shows the
Expand Down
14 changes: 12 additions & 2 deletions docs/SPEC.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,20 @@ transcript,仅在唯一自动阈值被跨越时安装 provider 可见的短 **
若已解除压力则不调摘要模型;否则将连续旧前缀摘要,并仅原样保留最近
**16%** 窗口,边界不拆分 assistant tool-call/tool-result 组。
- 摘要请求复用原 system、选中消息前缀和普通请求的 tools schema,只在最后追加
user compaction instruction,以复用 provider KV Cache。输出上限为 **8192 tokens**。
user compaction instruction,以复用 provider KV Cache。输出上限为 **8192 tokens**,
前缀规划另在其下预留窗口的 **5%**(至少 256 tokens)作为估算余量。
pressure 最多两次成功摘要,overflow 最多一次摘要且原请求最多重试一次。
overflow 救援可折叠当前 turn 已完成的轮次,最新两轮原样保留。
- 每次摘要回复(成功或 provider 超窗)都把真实 prompt 数回灌估算器。摘要请求
本身被 provider 拒绝时,先按修正后的估算重新规划更小前缀(最多两次),再以
有界转录形式发送一次(工具结果截到 2000 字符、不带 tools schema);手动压缩
随后可走分片路径。自动尝试失败后,同一 turn 内暂停重试,直到视图较该次尝试
再增长窗口的 5%,因此单个 turn 的重试次数有界。
- 候选必须严格小于被替换请求。摘要 timeout/error/空输出/token cap 都不会伪造
机械 digest;硬上限或 overflow 下 prune 仍不足时返回 `ErrCompactionRequired`。
机械 digest;硬上限以下沿用最近的持久投影。硬上限或 overflow 下摘要无法形成时,
改为有损的 `truncate` 投影:先抹去最旧的工具结果,再丢弃最旧的回放单元,
留下明确标记,直到视图回到阈值以下;只有连这样也回收不够时才返回
`ErrCompactionRequired`。
- 用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改阈值。
项目配置优先于桌面与新 CLI 会话共用的用户全局配置。UI 始终展示**实际生效**值。
- `max_output_tokens` 是独立的**本轮**输出上限,**绝不**改变 `triggerTokens` / `compact_ratio`。
Expand Down
32 changes: 19 additions & 13 deletions internal/agent/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,20 @@ Rules: be terse — bullet points and fragments, not prose. Preserve identifiers
// at send time and must never make compaction happen earlier than the user's
// configured compact_ratio.
func (a *Agent) compact(ctx context.Context, trigger, instructions string, force bool) error {
allowChunked := trigger == CompactionTriggerManual
_, err := a.compactToProjectionWithChunked(ctx, trigger, instructions, force, false, allowChunked)
_, err := a.compactToProjectionWithChunked(ctx, trigger, instructions, foldRequest{
force: force, allowChunked: trigger == CompactionTriggerManual,
})
return err
}

func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions string, force, mustFree bool) (CompactionOutcome, error) {
return a.compactToProjectionWithChunked(ctx, trigger, instructions, force, mustFree, false)
return a.compactToProjectionWithChunked(ctx, trigger, instructions, foldRequest{force: force, mustFree: mustFree})
}

func (a *Agent) compactToProjectionWithChunked(ctx context.Context, trigger, instructions string, force, mustFree, allowChunked bool) (CompactionOutcome, error) {
func (a *Agent) compactToProjectionWithChunked(ctx context.Context, trigger, instructions string, req foldRequest) (CompactionOutcome, error) {
a.sess.compactionRunMu.Lock()
defer a.sess.compactionRunMu.Unlock()
return a.compactToProjectionLocked(ctx, trigger, instructions, force, mustFree, allowChunked)
return a.compactToProjectionLocked(ctx, trigger, instructions, req)
}

func (a *Agent) compactTrigger() int {
Expand Down Expand Up @@ -408,8 +409,16 @@ func (a *Agent) summaryRequest(region []provider.Message, instructions string) p

// summarize asks the executor's own provider to distill a replayed prefix into
// a briefing. instructions is optional /compact focus + PreCompact text.
func (a *Agent) summarize(ctx context.Context, region []provider.Message, instructions string) (string, *provider.Usage, error) {
req := a.summaryRequest(region, instructions)
summary, usage, err := a.runSummaryRequest(ctx, req)
a.observeSummaryOutcome(req, usage, err)
return summary, usage, err
}

// runSummaryRequest admits, sends, and drains one summary request.
// 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) {
func (a *Agent) runSummaryRequest(ctx context.Context, req provider.Request) (summary string, usage *provider.Usage, err error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = provider.WithRequestAttemptCounter(ctx)
Expand All @@ -420,7 +429,6 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
}
}()
defer trackPublishedHostStream(ctx, cancel)()
req := a.summaryRequest(region, instructions)
if err := a.applySummaryAdmissionToRequest(&req); err != nil {
return "", usage, err
}
Expand Down Expand Up @@ -487,7 +495,9 @@ func (a *Agent) summarizeOnce(ctx context.Context, fold []provider.Message, inst
return a.summarize(ctx, fold, instructions)
}

// renderTranscript flattens messages into a readable transcript for summarization.
// renderTranscript flattens messages into a bounded transcript for the
// transcript-form summary request. Tool bodies are the provider-visible
// Content cut to slimToolResultRunes; RawContent never enters a summary.
func renderTranscript(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
Expand All @@ -506,11 +516,7 @@ func renderTranscript(msgs []provider.Message) string {
}
b.WriteString("\n")
case provider.RoleTool:
body := m.Content
if m.RawContent != "" {
body = m.RawContent
}
fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, body)
fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, slimToolResult(m.Content))
case provider.RoleSystem:
fmt.Fprintf(&b, "[system]\n%s\n\n", m.Content)
}
Expand Down
33 changes: 33 additions & 0 deletions internal/agent/compact_active_turn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package agent

import (
"slices"

"reasonix/internal/provider"
)

// activeTurnKeepRounds is how many of the active turn's newest assistant
// rounds stay verbatim when an overflow forces a fold inside the turn.
const activeTurnKeepRounds = 2

// activeTurnFoldBoundary returns where an overflow rescue may end its fold
// inside the active turn: after the prompt and every completed round except
// the newest keep rounds, on a replay-safe unit boundary. A turn with too few
// rounds to split returns active, keeping the whole turn verbatim.
func activeTurnFoldBoundary(msgs []provider.Message, active, end int) int {
if active < 0 || end <= active+1 || end > len(msgs) {
return active
}
body := msgs[active+1 : end]
keep := activeTurnKeepRounds
for _, unit := range slices.Backward(extractMessageUnits(body)) {
if body[unit.lo].Role != provider.RoleAssistant {
continue
}
keep--
if keep < 0 {
return active + 1 + unit.hi
}
}
return active
}
8 changes: 5 additions & 3 deletions internal/agent/compact_chunked_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import (
func TestPressureCompactionDoesNotCallChunkedFold(t *testing.T) {
prov := &extractStubProvider{failFirst: 64, reply: "digest"}
a := agentOverForce(t, prov, foldableSessionOverForce(12))
err := prepareContext(context.Background(), a, CompactionTriggerPressure)
if err == nil {
t.Fatal("truncated summary must fail without installing a chunked projection")
if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil {
t.Fatalf("prepare = %v, want the truncation rescue instead of a chunked projection", err)
}
if degradedFold(a) {
t.Fatal("pressure compaction must not install a fabricated summary")
}
if !truncatedRescue(a) {
t.Fatalf("receipt = %+v, want the truncation rescue, not a chunked digest", a.sess.compactionState.LastReceipt)
}
if prov.calls > 2 {
t.Fatalf("provider calls = %d, want at most one summary plus one retry, not chunked/tree-reduce", prov.calls)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/agent/compact_fold_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func (a *Agent) foldToSummary(ctx context.Context, fold []provider.Message, inst

func (a *Agent) foldToSummaryMode(ctx context.Context, fold []provider.Message, instructions, inputMode string) (foldSummary, error) {
res := foldSummary{Mode: CompactionModeSummarized, Spans: 1, FoldTokens: summaryInputTokens(fold), InputMode: inputMode}
if inputMode == SummaryInputSlim {
summary, usage, err := a.summarizeTranscript(ctx, fold, instructions)
res.Text, res.Usage = summary, usage
return res, err
}
return a.singleCallSummary(ctx, res, fold, instructions)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/agent/compact_overflow_prefix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestOverflowSummarizesLargestAdmissibleContiguousPrefix(t *testing.T) {
prov := &overflowSummaryProvider{}
a := agentOverForceWindow(t, prov, sess, 60_000)
msgs := sess.Snapshot()
head, plannedEnd, ok := a.planFoldRegion(msgs, true)
head, plannedEnd, ok := a.planFoldRegion(msgs, true, false)
if !ok {
t.Fatal("fixture has no foldable prefix")
}
Expand Down Expand Up @@ -130,7 +130,7 @@ func TestPressureSummaryCappedByLearnedWindowAfterSessionReset(t *testing.T) {
}

msgs := sess.Snapshot()
head, plannedEnd, ok := a.planFoldRegion(msgs, true)
head, plannedEnd, ok := a.planFoldRegion(msgs, true, false)
if !ok {
t.Fatal("fixture has no foldable prefix")
}
Expand Down
Loading
Loading