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
9 changes: 9 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,15 @@ the keep policy protects also survive, though a failure with a recorded executio
keeps only its failure-carrying lines. Everything else is **best-effort** — it
reaches the summarizer and survives only as well as the digest captured it.

That protection has to hold across *repeated* folds, which is why a stored
projection keeps the host's `ToolExecution` record while a provider request does
not. `KeepErrors` classifies a failure from that record rather than from text,
because a real `go test` log opens with `=== RUN` and no prefix match can see
it; a projection written without the record would leave the *next* fold unable
to classify what the current one just protected. The strip therefore belongs at
the provider boundary — `ModelMessages` — and not at projection write time,
where `ProjectionMessages` preserves it.

User turns are held to a different standard than the work they govern. A
constraint stated at turn 4 ("do not change the public API") exists nowhere but
the transcript, while the code it constrains stays re-derivable from the
Expand Down
5 changes: 5 additions & 0 deletions docs/SPEC.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ transcript,仅在唯一自动阈值被跨越时安装 provider 可见的短 **
丢弃不是静默的:压缩 telemetry 带 `user_kept` / `user_dropped` 计数,且已提交的
checkpoint 若折叠了用户轮次会发出提示 `[[keep]]` 的警告——两种情况下 projection
读起来都是完整的,计数是唯一能区分它们的东西。
- **失败保护必须跨多次折叠成立**:`KeepErrors` 依据宿主的 `ToolExecution` 记录而非
文本判定失败(真实 `go test` 日志以 `=== RUN` 开头,前缀匹配看不见它),因此存储的
projection 保留该记录,而发往 provider 的请求不带。剥离发生在 provider 边界
(`ModelMessages`),projection 写入用 `ProjectionMessages` 保留——否则下一次折叠
将无法分类上一次刚刚保护下来的失败。
- 用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改阈值。
项目配置优先于桌面与新 CLI 会话共用的用户全局配置。UI 始终展示**实际生效**值。
- `max_output_tokens` 是独立的**本轮**输出上限。
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/compact_projection.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ func checkpointProjectionMessages(msgs []provider.Message, head, start int, kept
projMsgs = append(projMsgs, formatSummaryMessage(summary))
projMsgs = append(projMsgs, kept...)
projMsgs = append(projMsgs, msgs[start:]...)
return provider.ModelMessages(projMsgs)
return provider.ProjectionMessages(projMsgs)
}

// acceptCheckpointCandidate: ≤50% + smaller for auto; force may exceed 50%
Expand Down
78 changes: 78 additions & 0 deletions internal/agent/failure_survives_folds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package agent

import (
"context"
"fmt"
"strings"
"testing"

"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)

// goTestFailure is shaped like the real thing on purpose: a failing `go test`
// log opens with "=== RUN", so the keep policy's text-prefix arm cannot see it
// and only ToolExecution can. Synthetic "error: ..." fixtures survive either
// way and would have hidden this.
func goTestFailure() string {
lines := make([]string, 0, 40)
for i := range 40 {
if i == 20 {
lines = append(lines, "--- FAIL: TestCanary (0.01s)")
continue
}
lines = append(lines, fmt.Sprintf("=== RUN TestCase%03d", i))
}
return strings.Join(lines, "\n")
}

// KeepErrors reads ToolExecution because text matching was proven to miss real
// failures. That record has to outlive the fold that first protects it: a
// projection is the next compaction's input, so a failure stripped at write time
// is invisible to the pass after next and the model silently stops seeing it.
func TestRecordedFailureSurvivesRepeatedCompaction(t *testing.T) {
bulk := strings.Repeat("work output line with detail. ", 250)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
}}
a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess,
Options{ContextWindow: 8000, CompactRatio: 0.85, RecentKeep: 2,
KeepPolicy: KeepErrors, ArchiveDir: t.TempDir()}, event.Discard)

exit := 1
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "running tests",
ToolCalls: []provider.ToolCall{{ID: "canary", Name: "bash", Arguments: "{}"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "canary", Name: "bash",
Content: goTestFailure(), ToolExecution: &provider.ToolExecution{ExitCode: &exit}})

// Three folds: the first protects the failure, and the ones after it are
// where the record used to be gone.
for round := 1; round <= 3; round++ {
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: bulk})
sess.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("continue %d", round)})
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("round %d compact: %v", round, err)
}

proj := visibleContext(a)
var carrier *provider.Message
for i, m := range proj {
if strings.Contains(m.Content, "TestCanary") {
carrier = &proj[i]
}
}
if carrier == nil {
t.Fatalf("round %d: the failure left the projection; the model is no longer told about it", round)
}
if carrier.ToolExecution == nil {
t.Fatalf("round %d: the failure record was stripped, so the next fold cannot classify it", round)
}
for _, m := range provider.ModelMessages(proj) {
if m.ToolExecution != nil {
t.Fatalf("round %d: local shell metadata reached the provider request", round)
}
}
}
}
49 changes: 49 additions & 0 deletions internal/provider/projection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package provider

// Two copies of a transcript are derived from the stored one: the bytes a
// provider receives, and the projection compaction writes back. They differ in
// exactly one field, and that difference is load-bearing.

// ModelMessages removes durable display-only records before a request is
// handed to any provider. Healthy sessions without such records keep their
// original backing slice, preserving the allocation and prompt-cache fast path.
func ModelMessages(msgs []Message) []Message { return projectMessages(msgs, false) }

// ProjectionMessages is ModelMessages for a stored projection, except that
// ToolExecution survives: a projection is also the next compaction's input, and
// only that record says a tool call failed. Stripping it here would leave the
// pass after next unable to classify the failure, so the strip belongs at the
// provider boundary, which every request path already crosses.
func ProjectionMessages(msgs []Message) []Message { return projectMessages(msgs, true) }

func projectMessages(msgs []Message, keepExecution bool) []Message {
needsCopy := false
for _, m := range msgs {
if m.LocalOnly || m.RawContent != "" || m.ProviderContent != "" || m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0 || (m.ToolExecution != nil && !keepExecution) {
needsCopy = true
break
}
}
if !needsCopy {
return msgs
}
out := make([]Message, 0, len(msgs))
for _, candidate := range msgs {
if candidate.LocalOnly {
continue
}
if candidate.ProviderContent != "" {
candidate.Content = candidate.ProviderContent
candidate.ProviderContent = ""
}
candidate.RawContent = ""
candidate.DecisionReceipt = nil
candidate.DecisionReceipts = nil
if !keepExecution {
// Local shell metadata must never enter provider request bytes.
candidate.ToolExecution = nil
}
out = append(out, candidate)
}
return out
}
33 changes: 0 additions & 33 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,39 +282,6 @@ const interruptedToolResult = "[no result: the previous turn was interrupted bef
// "defensive wire prep" rather than "session mutation".
func SanitizeToolPairing(msgs []Message) []Message { return NormalizeMessages(msgs) }

// ModelMessages removes durable display-only records before a request is
// handed to any provider. Healthy sessions without such records keep their
// original backing slice, preserving the allocation and prompt-cache fast path.
func ModelMessages(msgs []Message) []Message {
needsCopy := false
for _, m := range msgs {
if m.LocalOnly || m.RawContent != "" || m.ProviderContent != "" || m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0 || m.ToolExecution != nil {
needsCopy = true
break
}
}
if !needsCopy {
return msgs
}
out := make([]Message, 0, len(msgs))
for _, candidate := range msgs {
if candidate.LocalOnly {
continue
}
if candidate.ProviderContent != "" {
candidate.Content = candidate.ProviderContent
candidate.ProviderContent = ""
}
candidate.RawContent = ""
candidate.DecisionReceipt = nil
candidate.DecisionReceipts = nil
// Local shell metadata must never enter provider request bytes.
candidate.ToolExecution = nil
out = append(out, candidate)
}
return out
}

// NormalizeMessages repairs a conversation history so it satisfies the tool-call
// contract the OpenAI-compatible and Anthropic APIs enforce: every assistant
// tool_calls entry must be answered by a following tool message for its id, and a
Expand Down
34 changes: 34 additions & 0 deletions internal/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,40 @@ func TestModelMessagesStripsRawContentWithoutChangingLegacyContent(t *testing.T)
}
}

// A stored projection is read twice: as what the model is sent, and as the input
// the next compaction classifies. Only ToolExecution says a tool call failed, so
// the strip has to happen at the provider boundary rather than at write time.
func TestProjectionMessagesKeepsExecutionThatModelMessagesStrips(t *testing.T) {
exit := 1
stored := []Message{
{Role: RoleUser, Content: "task"},
{Role: RoleTool, ToolCallID: "c1", Name: "bash", Content: "=== RUN\n--- FAIL: TestX",
RawContent: "the whole log", ToolExecution: &ToolExecution{ExitCode: &exit}},
{Role: RoleTool, ToolCallID: "local", Name: "x", Content: "display only", LocalOnly: true},
}

proj := ProjectionMessages(stored)
if len(proj) != 2 {
t.Fatalf("projection kept display-only output: %+v", proj)
}
if proj[1].ToolExecution == nil {
t.Fatal("projection dropped the failure record the next compaction classifies on")
}
if proj[1].RawContent != "" {
t.Fatalf("projection kept unbounded raw content: %+v", proj[1])
}

// The same messages, once they are actually going to a provider.
for i, m := range ModelMessages(proj) {
if m.ToolExecution != nil {
t.Fatalf("local shell metadata reached the wire at index %d: %+v", i, m)
}
}
if stored[1].ToolExecution == nil {
t.Fatal("stored message was mutated")
}
}

func TestLocalOnlySentinelIsSafeWhenNewFieldsAreIgnoredByLegacyReader(t *testing.T) {
legacyView := []Message{
{Role: RoleUser, Content: "task"},
Expand Down