WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agenttoken "github.com/Tencent/WeKnora/internal/agent/token"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubChat records what the summarizer was asked and returns a canned reply.
|
||||
type stubChat struct {
|
||||
response string
|
||||
finishReason string
|
||||
err error
|
||||
calls int
|
||||
prompts []string
|
||||
}
|
||||
|
||||
func (s *stubChat) Chat(
|
||||
_ context.Context, messages []chat.Message, _ *chat.ChatOptions,
|
||||
) (*types.ChatResponse, error) {
|
||||
s.calls++
|
||||
if len(messages) > 1 {
|
||||
s.prompts = append(s.prompts, messages[len(messages)-1].Content)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &types.ChatResponse{Content: s.response, FinishReason: s.finishReason}, nil
|
||||
}
|
||||
|
||||
func (s *stubChat) ChatStream(
|
||||
context.Context, []chat.Message, *chat.ChatOptions,
|
||||
) (<-chan types.StreamResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubChat) GetModelName() string { return "stub" }
|
||||
func (s *stubChat) GetModelID() string { return "stub" }
|
||||
|
||||
func newEstimator(t *testing.T) *agenttoken.Estimator {
|
||||
t.Helper()
|
||||
est, err := agenttoken.NewEstimator()
|
||||
require.NoError(t, err)
|
||||
return est
|
||||
}
|
||||
|
||||
func testSettings() Settings {
|
||||
return Settings{
|
||||
Enabled: true,
|
||||
MaxContextTokens: 40000,
|
||||
ReserveTokens: 8000,
|
||||
KeepRecentTokens: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
// filler produces text large enough to matter against the token budgets above.
|
||||
func filler(n int) string { return strings.Repeat("some conversation content ", n) }
|
||||
|
||||
// reactTurn builds what an agent turn actually looks like: one user message
|
||||
// followed by many assistant/tool rounds, with no further user message. This
|
||||
// is the shape that the previous "never touch the current turn" rule made
|
||||
// impossible to compact.
|
||||
func reactTurn(rounds int) []chat.Message {
|
||||
msgs := []chat.Message{
|
||||
{Role: "system", Content: "you are an agent"},
|
||||
{Role: "user", Content: "build me a deck"},
|
||||
}
|
||||
for i := 0; i < rounds; i++ {
|
||||
msgs = append(msgs,
|
||||
chat.Message{
|
||||
Role: "assistant",
|
||||
Content: filler(20),
|
||||
ToolCalls: []chat.ToolCall{{
|
||||
ID: "call-" + string(rune('a'+i%26)),
|
||||
Type: "function",
|
||||
Function: chat.FunctionCall{
|
||||
Name: "write_sandbox_file",
|
||||
Arguments: `{"path":"/workspace/out.html","content":"` + filler(40) + `"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
chat.Message{
|
||||
Role: "tool",
|
||||
Name: "write_sandbox_file",
|
||||
ToolCallID: "call-" + string(rune('a'+i%26)),
|
||||
Content: filler(30),
|
||||
},
|
||||
)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// The regression this package exists for: a single ReAct turn with no second
|
||||
// user message must still compact. The old cut rule exempted everything after
|
||||
// the last user message, so a twenty-round turn was entirely untouchable and
|
||||
// every round paid for a summarization that freed nothing.
|
||||
func TestCompactsInsideASingleTurn(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "## Goal\nbuild a deck"}
|
||||
c := New(llm, est, testSettings())
|
||||
require.NotNil(t, c)
|
||||
|
||||
msgs := reactTurn(40)
|
||||
require.Equal(t, 1, countRole(msgs, "user"), "fixture must have no turn boundary to hide behind")
|
||||
|
||||
result, err := c.Compact(context.Background(), msgs, ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Greater(t, result.Freed(), 0, "compaction must actually shrink the context")
|
||||
assert.Less(t, result.TokensAfter, result.TokensBefore/2)
|
||||
assert.True(t, result.SplitTurn, "a turn larger than the budget has to be split")
|
||||
assert.Equal(t, "system", result.Messages[0].Role)
|
||||
assert.Equal(t, chat.MessageKindCompactionSummary, result.Messages[1].Kind)
|
||||
}
|
||||
|
||||
// Cutting mid-turn discards the user's original request, so the prefix gets its
|
||||
// own summary. Without it the retained tail is a pile of tool calls with no
|
||||
// statement of what they were for.
|
||||
func TestSplitTurnSummarizesThePrefixSeparately(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "summary text"}
|
||||
c := New(llm, est, testSettings())
|
||||
|
||||
result, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, result.SplitTurn)
|
||||
assert.Contains(t, result.Summary, "Turn Context (split turn)")
|
||||
require.GreaterOrEqual(t, len(llm.prompts), 1)
|
||||
assert.Contains(t, llm.prompts[len(llm.prompts)-1], "PREFIX of a turn",
|
||||
"the prefix must be summarized with the prefix instructions, not the history ones")
|
||||
}
|
||||
|
||||
// The retained tail has to stay valid for the provider: a tool result whose
|
||||
// originating assistant message was summarized away is rejected outright.
|
||||
func TestKeptTailNeverStartsWithAnOrphanToolResult(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
c := New(&stubChat{response: "s"}, est, testSettings())
|
||||
|
||||
for rounds := 4; rounds <= 40; rounds++ {
|
||||
result, err := c.Compact(context.Background(), reactTurn(rounds), ReasonThreshold)
|
||||
if errors.Is(err, ErrNothingToCompact) {
|
||||
continue // still inside the keep-recent budget
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
pending := map[string]bool{}
|
||||
for _, msg := range result.Messages {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
pending[tc.ID] = true
|
||||
}
|
||||
if msg.Role == "tool" {
|
||||
assert.True(t, pending[msg.ToolCallID],
|
||||
"rounds=%d: tool result %s has no preceding call", rounds, msg.ToolCallID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The keep-recent budget is a ceiling. Treating it as a floor — stop at the
|
||||
// first message that reaches the budget and keep that message too — is what
|
||||
// made compaction fire every couple of rounds: one knowledge_search result is
|
||||
// routinely larger than the whole budget, so the retained tail overshot and
|
||||
// left almost no headroom before the next threshold crossing.
|
||||
func TestRetainedTailStaysWithinTheKeepRecentBudget(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
s := testSettings()
|
||||
c := New(&stubChat{response: "summary"}, est, s)
|
||||
|
||||
// A search result far larger than the budget, followed by small rounds.
|
||||
msgs := []chat.Message{
|
||||
{Role: "system", Content: "you are an agent"},
|
||||
{Role: "user", Content: "research this"},
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
id := "search-" + string(rune('a'+i))
|
||||
msgs = append(msgs,
|
||||
chat.Message{Role: "assistant", ToolCalls: []chat.ToolCall{{
|
||||
ID: id, Type: "function",
|
||||
Function: chat.FunctionCall{Name: "knowledge_search", Arguments: `{"query":"x"}`},
|
||||
}}},
|
||||
chat.Message{Role: "tool", Name: "knowledge_search", ToolCallID: id, Content: filler(600)},
|
||||
)
|
||||
}
|
||||
|
||||
result, err := c.Compact(context.Background(), msgs, ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Measure the verbatim tail only: the summary is budgeted separately.
|
||||
tail := result.Messages[2:]
|
||||
tailTokens := est.EstimateMessages(tail)
|
||||
assert.LessOrEqual(t, tailTokens, s.Normalize().KeepRecentTokens,
|
||||
"the retained tail must fit the budget, not merely reach it")
|
||||
}
|
||||
|
||||
// The point of compaction is the headroom it leaves, not the tokens it frees.
|
||||
// Landing just under the threshold counts as a successful compaction and still
|
||||
// re-triggers a round or two later, which is what the user sees as "constantly
|
||||
// compacting".
|
||||
func TestCompactionLeavesRoomForSeveralMoreRounds(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
s := testSettings().Normalize()
|
||||
c := New(&stubChat{response: strings.Repeat("summary line\n", 40)}, est, s)
|
||||
|
||||
result, err := c.Compact(context.Background(), reactTurn(40), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Less(t, result.TokensAfter, s.Threshold()*2/3,
|
||||
"post-compaction context must sit well below the threshold, not just under it")
|
||||
assert.False(t, s.ShouldCompact(result.TokensAfter),
|
||||
"a compaction that still leaves the context over the threshold is a no-op")
|
||||
}
|
||||
|
||||
// Compacting an already-compacted context must report that it cannot help,
|
||||
// rather than spending another summarization call to produce the same result.
|
||||
// This is what stops the every-round loop.
|
||||
func TestSecondCompactionReportsNothingToDo(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "## Goal\nbuild a deck"}
|
||||
c := New(llm, est, testSettings())
|
||||
|
||||
first, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
callsAfterFirst := llm.calls
|
||||
|
||||
_, err = c.Compact(context.Background(), first.Messages, ReasonThreshold)
|
||||
require.ErrorIs(t, err, ErrNothingToCompact)
|
||||
assert.Equal(t, callsAfterFirst, llm.calls, "a no-op compaction must not call the LLM")
|
||||
}
|
||||
|
||||
// A summary must never be summarized again: successive passes would degrade it
|
||||
// into a summary of a summary. It is instead handed to the update prompt as
|
||||
// prior context, and only the messages after it are new input.
|
||||
func TestPreviousSummaryIsUpdatedNotResummarized(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "## Goal\nfirst pass"}
|
||||
c := New(llm, est, testSettings())
|
||||
|
||||
first, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
// New work arrives after the compaction, so there is something to fold in.
|
||||
grown := append(append([]chat.Message{}, first.Messages...), reactTurn(10)[2:]...)
|
||||
llm.prompts = nil
|
||||
second, err := c.Compact(context.Background(), grown, ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
prompt := llm.prompts[0]
|
||||
assert.Contains(t, prompt, "<previous-summary>")
|
||||
assert.Contains(t, prompt, "first pass")
|
||||
conversation := prompt[strings.Index(prompt, "<conversation>"):strings.Index(prompt, "</conversation>")]
|
||||
assert.NotContains(t, conversation, "compacted into the following summary",
|
||||
"the prior summary must not re-enter as conversation input")
|
||||
|
||||
assert.Equal(t, 1, countKind(second.Messages, chat.MessageKindCompactionSummary),
|
||||
"exactly one summary survives; they must not stack")
|
||||
}
|
||||
|
||||
// A summary cut off by the token cap reads like a valid checkpoint but ends
|
||||
// mid-section, and every later round inherits that truncation as its only
|
||||
// memory. A length stop is a failure, not a result.
|
||||
func TestTruncatedSummaryIsRejected(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "## Goal\nhalf a sum", finishReason: "length"}
|
||||
c := New(llm, est, testSettings())
|
||||
|
||||
result, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.Degraded)
|
||||
assert.Contains(t, result.Summary, "Raw conversation archive")
|
||||
assert.NotContains(t, result.Summary, "half a sum")
|
||||
}
|
||||
|
||||
func TestSummarizerFailureFallsBackToArchive(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
c := New(&stubChat{err: assert.AnError}, est, testSettings())
|
||||
|
||||
result, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.Degraded)
|
||||
assert.Greater(t, result.Freed(), 0, "a degraded summary still has to free room")
|
||||
assert.Contains(t, result.Summary, "Raw conversation archive")
|
||||
}
|
||||
|
||||
// Sandbox paths are the detail a prose summarizer drops first, and losing them
|
||||
// makes the agent rebuild files it already wrote.
|
||||
func TestFilePathsSurviveCompaction(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
c := New(&stubChat{response: "prose that mentions no paths"}, est, testSettings())
|
||||
|
||||
result, err := c.Compact(context.Background(), reactTurn(12), ReasonThreshold)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result.Summary, "<modified-files>")
|
||||
assert.Contains(t, result.Summary, "/workspace/out.html")
|
||||
}
|
||||
|
||||
// A conversation that already fits has nothing outside the keep-recent budget.
|
||||
func TestSmallConversationIsLeftAlone(t *testing.T) {
|
||||
est := newEstimator(t)
|
||||
llm := &stubChat{response: "s"}
|
||||
c := New(llm, est, testSettings())
|
||||
|
||||
_, err := c.Compact(context.Background(), []chat.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "hi"},
|
||||
{Role: "assistant", Content: "hello"},
|
||||
}, ReasonThreshold)
|
||||
|
||||
require.ErrorIs(t, err, ErrNothingToCompact)
|
||||
assert.Zero(t, llm.calls)
|
||||
}
|
||||
|
||||
func TestKeepRecentIsScaledDownOnSmallWindows(t *testing.T) {
|
||||
// A 20k window cannot keep the 20k default and still have room for the
|
||||
// reserve and the summary; keeping it would guarantee a re-trigger.
|
||||
s := Settings{MaxContextTokens: 20000, ReserveTokens: 8000}.Normalize()
|
||||
assert.Equal(t, 3000, s.KeepRecentTokens, "a quarter of the usable window")
|
||||
|
||||
// A large window keeps the default untouched.
|
||||
big := Settings{MaxContextTokens: 200000, ReserveTokens: 16384}.Normalize()
|
||||
assert.Equal(t, DefaultKeepRecentTokens, big.KeepRecentTokens)
|
||||
}
|
||||
|
||||
func TestThresholdAndShouldCompact(t *testing.T) {
|
||||
s := Settings{Enabled: true, MaxContextTokens: 128000, ReserveTokens: 28672}.Normalize()
|
||||
assert.Equal(t, 128000-28672, s.Threshold())
|
||||
assert.False(t, s.ShouldCompact(s.Threshold()))
|
||||
assert.True(t, s.ShouldCompact(s.Threshold()+1))
|
||||
|
||||
// No window means no budget to enforce.
|
||||
assert.Zero(t, Settings{Enabled: true}.Threshold())
|
||||
assert.False(t, Settings{Enabled: true}.ShouldCompact(1_000_000))
|
||||
|
||||
// Disabled never triggers, whatever the size.
|
||||
assert.False(t, Settings{MaxContextTokens: 1000, ReserveTokens: 100}.
|
||||
Normalize().ShouldCompact(1_000_000))
|
||||
}
|
||||
|
||||
// Kind is engine bookkeeping; a provider must never see it.
|
||||
func TestSummaryKindIsNotSerialized(t *testing.T) {
|
||||
msg := SummaryMessage("text")
|
||||
encoded, err := marshalMessage(msg)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, encoded, "compaction_summary")
|
||||
assert.NotContains(t, encoded, "Kind")
|
||||
}
|
||||
|
||||
func marshalMessage(msg chat.Message) (string, error) {
|
||||
encoded, err := json.Marshal(msg)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func countRole(msgs []chat.Message, role string) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if m.Role == role {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countKind(msgs []chat.Message, kind chat.MessageKind) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if m.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agenttoken "github.com/Tencent/WeKnora/internal/agent/token"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxSummarizationAttempts bounds the retries for one compaction. Each
|
||||
// attempt is a full LLM round-trip, and a compaction that keeps failing is
|
||||
// better served by the raw archive than by stalling the turn.
|
||||
maxSummarizationAttempts = 2
|
||||
|
||||
// summarizationTimeout is the per-attempt budget for a summarization call.
|
||||
summarizationTimeout = 60 * time.Second
|
||||
|
||||
// llmCallLabel identifies compaction traffic in tracing and usage
|
||||
// accounting, separating it from the agent's own reasoning rounds.
|
||||
llmCallLabel = "agent_context_compaction"
|
||||
)
|
||||
|
||||
// Reason records what asked for a compaction, for logging and for the UI.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
// ReasonThreshold is the routine case: context crossed the budget.
|
||||
ReasonThreshold Reason = "threshold"
|
||||
// ReasonOverflow is repair after the provider rejected or truncated a
|
||||
// request because the window was full.
|
||||
ReasonOverflow Reason = "overflow"
|
||||
)
|
||||
|
||||
// ErrNothingToCompact means the context cannot be made smaller: everything
|
||||
// outside the keep-recent budget is already gone. Callers must treat this as a
|
||||
// stop signal rather than retrying, or they reproduce the every-round
|
||||
// summarization loop this package exists to prevent.
|
||||
var ErrNothingToCompact = errors.New("compaction: nothing outside the keep-recent budget")
|
||||
|
||||
// Result describes what one compaction did.
|
||||
type Result struct {
|
||||
Messages []chat.Message
|
||||
Summary string
|
||||
Reason Reason
|
||||
TokensBefore int
|
||||
TokensAfter int
|
||||
MessagesBefore int
|
||||
MessagesAfter int
|
||||
// Degraded reports that at least one summary came from the raw archive
|
||||
// because the summarizer failed. The context still shrank; its memory is
|
||||
// just coarser.
|
||||
Degraded bool
|
||||
// SplitTurn reports that the cut divided a single turn, so a turn-prefix
|
||||
// summary was generated alongside the history summary.
|
||||
SplitTurn bool
|
||||
}
|
||||
|
||||
// Freed is how much room the compaction actually recovered. A non-positive
|
||||
// value means the summary cost as much as the history it replaced.
|
||||
func (r *Result) Freed() int {
|
||||
return r.TokensBefore - r.TokensAfter
|
||||
}
|
||||
|
||||
// Compactor summarizes conversation history into a checkpoint.
|
||||
type Compactor struct {
|
||||
chatModel chat.Chat
|
||||
estimator *agenttoken.Estimator
|
||||
settings Settings
|
||||
}
|
||||
|
||||
// New builds a compactor. It returns nil when compaction cannot run, so the
|
||||
// caller can treat a nil compactor as "feature off" without a second flag.
|
||||
func New(chatModel chat.Chat, estimator *agenttoken.Estimator, settings Settings) *Compactor {
|
||||
if chatModel == nil || estimator == nil || settings.MaxContextTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &Compactor{chatModel: chatModel, estimator: estimator, settings: settings.Normalize()}
|
||||
}
|
||||
|
||||
// Settings returns the normalized settings, including the derived threshold.
|
||||
func (c *Compactor) Settings() Settings {
|
||||
if c == nil {
|
||||
return Settings{}
|
||||
}
|
||||
return c.settings
|
||||
}
|
||||
|
||||
// Compact replaces history older than the keep-recent budget with a summary.
|
||||
// It returns ErrNothingToCompact when no such history exists.
|
||||
func (c *Compactor) Compact(
|
||||
ctx context.Context, messages []chat.Message, reason Reason,
|
||||
) (*Result, error) {
|
||||
if c == nil {
|
||||
return nil, ErrNothingToCompact
|
||||
}
|
||||
prep := Prepare(messages, c.settings, c.estimator)
|
||||
if prep == nil {
|
||||
return nil, ErrNothingToCompact
|
||||
}
|
||||
|
||||
summary, degraded := c.buildSummary(ctx, prep)
|
||||
summary += prep.fileOps.format()
|
||||
compacted := Apply(messages, prep, summary)
|
||||
|
||||
return &Result{
|
||||
Messages: compacted,
|
||||
Summary: summary,
|
||||
Reason: reason,
|
||||
TokensBefore: prep.TokensBefore,
|
||||
TokensAfter: c.estimator.EstimateMessages(compacted),
|
||||
MessagesBefore: len(messages),
|
||||
MessagesAfter: len(compacted),
|
||||
Degraded: degraded,
|
||||
SplitTurn: prep.IsSplitTurn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildSummary produces the checkpoint text, falling back to a raw archive for
|
||||
// any part the summarizer could not produce.
|
||||
func (c *Compactor) buildSummary(ctx context.Context, p *Preparation) (string, bool) {
|
||||
degraded := false
|
||||
|
||||
history := p.PreviousSummary
|
||||
if len(p.MessagesToSummarize) > 0 {
|
||||
instructions := initialSummarizationInstructions
|
||||
if p.PreviousSummary != "" {
|
||||
instructions = updateSummarizationInstructions
|
||||
}
|
||||
text, err := c.summarize(
|
||||
ctx, p.MessagesToSummarize, p.PreviousSummary, instructions, c.settings.summaryBudget(),
|
||||
)
|
||||
if err != nil {
|
||||
degraded = true
|
||||
// The previous summary is still the best record of everything
|
||||
// before this span, so the archive is appended to it rather than
|
||||
// replacing it.
|
||||
history = joinNonEmpty(p.PreviousSummary, rawArchive(p.MessagesToSummarize))
|
||||
} else {
|
||||
history = text
|
||||
}
|
||||
}
|
||||
|
||||
if p.IsSplitTurn && len(p.TurnPrefixMessages) > 0 {
|
||||
prefix, err := c.summarize(
|
||||
ctx, p.TurnPrefixMessages, "", turnPrefixInstructions, c.settings.turnPrefixBudget(),
|
||||
)
|
||||
if err != nil {
|
||||
degraded = true
|
||||
prefix = rawArchive(p.TurnPrefixMessages)
|
||||
}
|
||||
if history == "" {
|
||||
history = "No prior history."
|
||||
}
|
||||
history += splitTurnSeparator + prefix
|
||||
}
|
||||
|
||||
return history, degraded
|
||||
}
|
||||
|
||||
// summarize runs one summarization call with retries.
|
||||
func (c *Compactor) summarize(
|
||||
ctx context.Context,
|
||||
messages []chat.Message,
|
||||
previousSummary, instructions string,
|
||||
maxTokens int,
|
||||
) (string, error) {
|
||||
prompt := buildSummarizationPrompt(messages, previousSummary, instructions)
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxSummarizationAttempts; attempt++ {
|
||||
callCtx, cancel := context.WithTimeout(ctx, summarizationTimeout)
|
||||
callCtx = types.WithLLMCallMetadata(callCtx, llmCallLabel, "")
|
||||
resp, err := c.chatModel.Chat(callCtx, []chat.Message{
|
||||
{Role: "system", Content: summarizationSystemPrompt},
|
||||
{Role: "user", Content: prompt},
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.3, // low temperature for factual summarization
|
||||
MaxTokens: maxTokens,
|
||||
CacheRetention: chat.CacheRetentionNone,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if err := validateSummary(resp); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(resp.Content), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("summarization failed after %d attempts: %w",
|
||||
maxSummarizationAttempts, lastErr)
|
||||
}
|
||||
|
||||
// validateSummary rejects responses that cannot serve as a checkpoint.
|
||||
//
|
||||
// The length stop is the one worth spelling out: a summary cut off by the token
|
||||
// cap reads like a valid summary but silently ends mid-section, and every later
|
||||
// round inherits that truncation as its only memory of the dropped history.
|
||||
// A partial summary is a failure: it cannot serve as a checkpoint.
|
||||
func validateSummary(resp *types.ChatResponse) error {
|
||||
if resp == nil || strings.TrimSpace(resp.Content) == "" {
|
||||
return errors.New("empty response from LLM")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(resp.FinishReason)) {
|
||||
case "length", "max_tokens", "max_output_tokens":
|
||||
return errors.New("generation hit the token cap and the summary is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSummarizationPrompt wraps the transcript in a tag and puts the
|
||||
// instructions last, so the summarizer cannot mistake conversation text for
|
||||
// its own instructions.
|
||||
func buildSummarizationPrompt(messages []chat.Message, previousSummary, instructions string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<conversation>\n")
|
||||
sb.WriteString(serializeConversation(messages))
|
||||
sb.WriteString("\n</conversation>\n\n")
|
||||
if previousSummary != "" {
|
||||
sb.WriteString("<previous-summary>\n")
|
||||
sb.WriteString(previousSummary)
|
||||
sb.WriteString("\n</previous-summary>\n\n")
|
||||
}
|
||||
sb.WriteString(instructions)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func joinNonEmpty(parts ...string) string {
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n\n")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
agenttoken "github.com/Tencent/WeKnora/internal/agent/token"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
)
|
||||
|
||||
// CutPoint is where the conversation is divided into "summarize this" and
|
||||
// "keep this verbatim".
|
||||
type CutPoint struct {
|
||||
// FirstKeptIdx is the index of the first message kept verbatim.
|
||||
FirstKeptIdx int
|
||||
// TurnStartIdx is the user message that opened the turn the cut lands in,
|
||||
// or -1 when the cut falls on a turn boundary.
|
||||
TurnStartIdx int
|
||||
// IsSplitTurn reports that the cut divides a single turn, so the part
|
||||
// before it needs its own summary to explain the part after it.
|
||||
IsSplitTurn bool
|
||||
}
|
||||
|
||||
// isCutPointMessage reports whether history may be cut immediately before this
|
||||
// message. Everything except a tool result qualifies. Tool results are excluded
|
||||
// because they must stay with the assistant message that requested them —
|
||||
// cutting between the two leaves an orphaned tool result and providers reject
|
||||
// the request outright.
|
||||
//
|
||||
// The corollary is what makes mid-turn compaction safe: a cut that lands on an
|
||||
// assistant message keeps that message and every tool result following it, so
|
||||
// the pairing is never split from either side.
|
||||
func isCutPointMessage(msg *chat.Message) bool {
|
||||
return msg.Role != "tool"
|
||||
}
|
||||
|
||||
// isTurnStartMessage reports whether this message opens a turn. Compaction
|
||||
// summaries count: they stand in for everything before them, so the turn they
|
||||
// begin is complete on its own.
|
||||
func isTurnStartMessage(msg *chat.Message) bool {
|
||||
return msg.Role == "user"
|
||||
}
|
||||
|
||||
// historyStart is the first index compaction may touch. The system prompt is
|
||||
// never a candidate — it carries the agent's instructions, not its history.
|
||||
func historyStart(messages []chat.Message) int {
|
||||
if len(messages) > 0 && messages[0].Role == "system" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FindCutPoint walks backwards from the newest message accumulating estimated
|
||||
// size, and keeps the largest suffix that still fits keepRecentTokens.
|
||||
//
|
||||
// The budget is a ceiling, not a floor. Stopping at the first message that
|
||||
// reaches the budget and keeping that message too is the obvious reading and
|
||||
// the wrong one: a single knowledge_search result is routinely ten thousand
|
||||
// tokens on its own, so "keep the recent 16k" would hand back 25k and leave
|
||||
// barely a round of headroom before the next compaction.
|
||||
//
|
||||
// Nothing here consults the current turn. That is deliberate: the budget is the
|
||||
// only rule, and a turn large enough to exceed it on its own gets split rather
|
||||
// than exempted.
|
||||
func FindCutPoint(
|
||||
messages []chat.Message,
|
||||
start, keepRecentTokens int,
|
||||
estimator *agenttoken.Estimator,
|
||||
) CutPoint {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
var cutPoints []int
|
||||
for i := start; i < len(messages); i++ {
|
||||
if isCutPointMessage(&messages[i]) {
|
||||
cutPoints = append(cutPoints, i)
|
||||
}
|
||||
}
|
||||
if len(cutPoints) == 0 {
|
||||
return CutPoint{FirstKeptIdx: start, TurnStartIdx: -1}
|
||||
}
|
||||
|
||||
// Worst case, keep only the newest group. A single message larger than the
|
||||
// whole budget cannot be cut any further here — trimming that is the tool
|
||||
// result trimmer's job, not the cut point's.
|
||||
cutIdx := cutPoints[len(cutPoints)-1]
|
||||
accumulated := 0
|
||||
for i := len(messages) - 1; i >= start; i-- {
|
||||
accumulated += estimator.EstimateMessage(&messages[i])
|
||||
if accumulated > keepRecentTokens {
|
||||
break
|
||||
}
|
||||
// Still within budget, so this is a better (earlier) place to cut.
|
||||
// Only legal cut points are recorded, which keeps every tool result
|
||||
// attached to the assistant message that requested it.
|
||||
if isCutPointMessage(&messages[i]) {
|
||||
cutIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if isTurnStartMessage(&messages[cutIdx]) {
|
||||
return CutPoint{FirstKeptIdx: cutIdx, TurnStartIdx: -1}
|
||||
}
|
||||
turnStart := findTurnStartIdx(messages, cutIdx, start)
|
||||
return CutPoint{
|
||||
FirstKeptIdx: cutIdx,
|
||||
TurnStartIdx: turnStart,
|
||||
IsSplitTurn: turnStart >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
// findTurnStartIdx returns the index of the user message that opened the turn
|
||||
// containing entryIdx, or -1 if the turn started before the searchable range.
|
||||
func findTurnStartIdx(messages []chat.Message, entryIdx, start int) int {
|
||||
for i := entryIdx; i >= start; i-- {
|
||||
if isTurnStartMessage(&messages[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package compaction
|
||||
|
||||
// Sandbox file operations have to survive compaction.
|
||||
//
|
||||
// Dropping the history that says "I wrote /workspace/output/deck.html" leaves
|
||||
// the model with no record of the artifacts it already produced, so it starts
|
||||
// rebuilding files that are already on disk — and for a file large enough to
|
||||
// need chunked writes, restarting is exactly the loop that never terminates.
|
||||
// The LLM summary is supposed to carry this, but it is prose and a list of
|
||||
// paths is the first detail a summarizer drops. So the paths are extracted
|
||||
// mechanically, re-attached to every summary, and inherited by the next one.
|
||||
//
|
||||
// Three sets while collecting, two lists when rendering: a file that was
|
||||
// written is reported only as modified even if it was also read.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
)
|
||||
|
||||
const (
|
||||
readFilesTag = "read-files"
|
||||
modifiedFilesTag = "modified-files"
|
||||
|
||||
// maxTrackedFilePaths bounds what the block can add to every subsequent
|
||||
// request. A session touching more files than this has its oldest entries
|
||||
// dropped rather than growing the prompt without limit.
|
||||
maxTrackedFilePaths = 50
|
||||
)
|
||||
|
||||
// fileOps is the set of sandbox paths the compacted history touched. Reads and
|
||||
// writes are kept apart while collecting so a path can be reclassified when a
|
||||
// later call writes something that was only read before.
|
||||
type fileOps struct {
|
||||
read []string
|
||||
written []string
|
||||
edited []string
|
||||
}
|
||||
|
||||
func appendUnique(list []string, path string) []string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || len(list) >= maxTrackedFilePaths {
|
||||
return list
|
||||
}
|
||||
for _, existing := range list {
|
||||
if existing == path {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, path)
|
||||
}
|
||||
|
||||
// extractFileOps collects the sandbox paths the summarized-away messages read
|
||||
// and wrote. previousSummary carries the block inherited from an earlier
|
||||
// compaction, which is no longer part of the message range being summarized
|
||||
// now that summaries are excluded from their own successor's input.
|
||||
func extractFileOps(previousSummary string, groups ...[]chat.Message) fileOps {
|
||||
var ops fileOps
|
||||
ops.inherit(previousSummary)
|
||||
for _, group := range groups {
|
||||
for _, msg := range group {
|
||||
ops.inherit(msg.Content)
|
||||
for _, tc := range msg.ToolCalls {
|
||||
path := toolCallPath(tc.Function.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
switch tc.Function.Name {
|
||||
case agenttools.ToolWriteSandboxFile:
|
||||
ops.written = appendUnique(ops.written, path)
|
||||
case agenttools.ToolEditSandboxFile:
|
||||
ops.edited = appendUnique(ops.edited, path)
|
||||
case agenttools.ToolReadSandboxFile:
|
||||
ops.read = appendUnique(ops.read, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// inherit folds an already-rendered block back into the collecting sets. Its
|
||||
// modified list has been through resolve(), so it goes back in as written.
|
||||
func (o *fileOps) inherit(content string) {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
inheritedRead, inheritedModified := parseFileOpsBlock(content)
|
||||
for _, p := range inheritedRead {
|
||||
o.read = appendUnique(o.read, p)
|
||||
}
|
||||
for _, p := range inheritedModified {
|
||||
o.written = appendUnique(o.written, p)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve collapses the three sets into what the model is shown: everything
|
||||
// touched is "modified", and only files never written are listed as read.
|
||||
func (o fileOps) resolve() (read, modified []string) {
|
||||
for _, p := range o.written {
|
||||
modified = appendUnique(modified, p)
|
||||
}
|
||||
for _, p := range o.edited {
|
||||
modified = appendUnique(modified, p)
|
||||
}
|
||||
for _, p := range o.read {
|
||||
if !contains(modified, p) {
|
||||
read = appendUnique(read, p)
|
||||
}
|
||||
}
|
||||
return read, modified
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, p := range list {
|
||||
if p == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolCallPath pulls the `path` argument out of a tool call. Malformed or
|
||||
// truncated arguments simply contribute nothing.
|
||||
func toolCallPath(arguments string) string {
|
||||
var args struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(args.Path)
|
||||
}
|
||||
|
||||
// format renders the block appended to a summary, or "" when nothing was
|
||||
// touched.
|
||||
func (o fileOps) format() string {
|
||||
read, modified := o.resolve()
|
||||
if len(read) == 0 && len(modified) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
writeTagged(&sb, readFilesTag, read)
|
||||
writeTagged(&sb, modifiedFilesTag, modified)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeTagged(sb *strings.Builder, tag string, paths []string) {
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "\n\n<%s>\n%s\n</%s>", tag, strings.Join(paths, "\n"), tag)
|
||||
}
|
||||
|
||||
// parseFileOpsBlock reads back what format wrote, so consecutive compactions
|
||||
// accumulate instead of each forgetting the one before it.
|
||||
func parseFileOpsBlock(content string) (read, modified []string) {
|
||||
return parseTagged(content, readFilesTag), parseTagged(content, modifiedFilesTag)
|
||||
}
|
||||
|
||||
func parseTagged(content, tag string) []string {
|
||||
open, closeTag := "<"+tag+">", "</"+tag+">"
|
||||
start := strings.Index(content, open)
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
start += len(open)
|
||||
end := strings.Index(content[start:], closeTag)
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, line := range strings.Split(content[start:start+end], "\n") {
|
||||
paths = appendUnique(paths, line)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func toolCallMsg(name, arguments string) chat.Message {
|
||||
return chat.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []chat.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: chat.FunctionCall{Name: name, Arguments: arguments},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSplitsReadsFromWrites(t *testing.T) {
|
||||
read, modified := extractFileOps("", []chat.Message{
|
||||
toolCallMsg("write_sandbox_file", `{"path":"/workspace/output/deck.html","content":"<html>"}`),
|
||||
toolCallMsg("edit_sandbox_file", `{"path":"/workspace/output/deck.html","old_string":"a"}`),
|
||||
toolCallMsg("read_sandbox_file", `{"path":"/workspace/input/notes.txt"}`),
|
||||
toolCallMsg("shell_exec", `{"command":"ls"}`),
|
||||
}).resolve()
|
||||
|
||||
// Written then edited is one entry, not two.
|
||||
assert.Equal(t, []string{"/workspace/output/deck.html"}, modified)
|
||||
assert.Equal(t, []string{"/workspace/input/notes.txt"}, read)
|
||||
}
|
||||
|
||||
// A file the agent read and then rewrote is only interesting as something it
|
||||
// modified; listing it twice invites the model to re-read its own output.
|
||||
func TestResolveDropsReadsThatWereAlsoWritten(t *testing.T) {
|
||||
read, modified := extractFileOps("", []chat.Message{
|
||||
toolCallMsg("read_sandbox_file", `{"path":"/workspace/output/deck.html"}`),
|
||||
toolCallMsg("write_sandbox_file", `{"path":"/workspace/output/deck.html","content":"x"}`),
|
||||
}).resolve()
|
||||
|
||||
assert.Equal(t, []string{"/workspace/output/deck.html"}, modified)
|
||||
assert.Empty(t, read)
|
||||
}
|
||||
|
||||
// Truncated arguments are the normal case for the calls this feature exists
|
||||
// to survive, so they must be skipped rather than poison the list.
|
||||
func TestExtractFileOpsIgnoresUnparseableArguments(t *testing.T) {
|
||||
read, modified := extractFileOps("", []chat.Message{
|
||||
toolCallMsg("write_sandbox_file", `{"path":"/workspace/output/a.html","content":"<htm`),
|
||||
toolCallMsg("write_sandbox_file", `{"content":"no path here"}`),
|
||||
}).resolve()
|
||||
|
||||
assert.Empty(t, modified)
|
||||
assert.Empty(t, read)
|
||||
}
|
||||
|
||||
// Without inheritance the second compaction forgets what the first one
|
||||
// recorded, which is the whole failure mode this guards against. The prior
|
||||
// block now arrives via the previous summary rather than as a message, because
|
||||
// summaries no longer re-enter their successor's input.
|
||||
func TestExtractFileOpsInheritsFromPreviousSummary(t *testing.T) {
|
||||
earlier := fileOps{
|
||||
written: []string{"/workspace/output/deck.html"},
|
||||
read: []string{"/workspace/input/notes.txt"},
|
||||
}
|
||||
|
||||
read, modified := extractFileOps("prose"+earlier.format(), []chat.Message{
|
||||
toolCallMsg("write_sandbox_file", `{"path":"/workspace/output/report.md","content":"x"}`),
|
||||
}).resolve()
|
||||
|
||||
assert.Equal(t, []string{"/workspace/output/deck.html", "/workspace/output/report.md"}, modified)
|
||||
assert.Equal(t, []string{"/workspace/input/notes.txt"}, read)
|
||||
}
|
||||
|
||||
func TestFileOpsFormatIsEmptyWhenNothingTouched(t *testing.T) {
|
||||
assert.Empty(t, fileOps{}.format())
|
||||
}
|
||||
|
||||
func TestFileOpsCapsTrackedPaths(t *testing.T) {
|
||||
msgs := make([]chat.Message, 0, maxTrackedFilePaths+10)
|
||||
for i := 0; i < maxTrackedFilePaths+10; i++ {
|
||||
msgs = append(msgs, toolCallMsg("write_sandbox_file",
|
||||
`{"path":"/workspace/output/`+strings.Repeat("a", i+1)+`.txt"}`))
|
||||
}
|
||||
|
||||
_, modified := extractFileOps("", msgs).resolve()
|
||||
assert.Len(t, modified, maxTrackedFilePaths)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Context overflow is worth detecting precisely because the recovery is
|
||||
// specific: compact and retry once. Misread as a generic failure it ends the
|
||||
// turn; misread as a transient error it retries the same oversized request
|
||||
// until the attempts run out. Patterns are collected from real provider
|
||||
// responses.
|
||||
var overflowPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)prompt is too long`), // Anthropic
|
||||
regexp.MustCompile(`(?i)request_too_large`), // Anthropic (HTTP 413)
|
||||
regexp.MustCompile(`(?i)input is too long for requested model`), // Amazon Bedrock
|
||||
regexp.MustCompile(`(?i)exceeds the context window`), // OpenAI
|
||||
regexp.MustCompile(`(?i)exceeds (the )?(model'?s )?maximum context length`), // OpenAI-compatible proxies
|
||||
regexp.MustCompile(`(?i)input token count.*exceeds the maximum`), // Google Gemini
|
||||
regexp.MustCompile(`(?i)maximum prompt length is \d+`), // xAI Grok
|
||||
regexp.MustCompile(`(?i)reduce the length of the messages`), // Groq
|
||||
regexp.MustCompile(`(?i)maximum context length is \d+ tokens`), // OpenRouter
|
||||
regexp.MustCompile(`(?i)exceeds (the )?maximum allowed input length`), // OpenRouter / Poolside
|
||||
regexp.MustCompile(`(?i)is longer than the model'?s context length`), // Together AI
|
||||
regexp.MustCompile(`(?i)exceeds the limit of \d+`), // GitHub Copilot
|
||||
regexp.MustCompile(`(?i)exceeds the available context size`), // llama.cpp
|
||||
regexp.MustCompile(`(?i)greater than the context length`), // LM Studio
|
||||
regexp.MustCompile(`(?i)context window exceeds limit`), // MiniMax
|
||||
regexp.MustCompile(`(?i)exceeded model token limit`), // Kimi
|
||||
regexp.MustCompile(`(?i)too large for model with \d+ maximum context length`), // Mistral
|
||||
regexp.MustCompile(`(?i)but the configured context size is`), // DS4
|
||||
regexp.MustCompile(`(?i)model_context_window_exceeded`), // z.ai
|
||||
regexp.MustCompile(`(?i)prompt too long; exceeded (max )?context length`), // Ollama
|
||||
regexp.MustCompile(`(?i)range of input length should be`), // DashScope / Qwen
|
||||
regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`), // generic
|
||||
regexp.MustCompile(`(?i)too many tokens`), // generic
|
||||
regexp.MustCompile(`(?i)token limit exceeded`), // generic
|
||||
}
|
||||
|
||||
// nonOverflowPatterns are errors that mention token counts while meaning
|
||||
// something else. Bedrock throttling reads "ThrottlingException: Too many
|
||||
// tokens, please wait before trying again", which matches an overflow pattern
|
||||
// and is not one. Matched anywhere in the message rather than anchored, since
|
||||
// WeKnora sees raw provider errors rather than a pre-normalized prefix.
|
||||
var nonOverflowPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)throttling`),
|
||||
regexp.MustCompile(`(?i)service unavailable`),
|
||||
regexp.MustCompile(`(?i)rate limit`),
|
||||
regexp.MustCompile(`(?i)too many requests`),
|
||||
}
|
||||
|
||||
// IsOverflowError reports whether a failed LLM call failed because the request
|
||||
// did not fit in the context window.
|
||||
func IsOverflowError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, p := range nonOverflowPatterns {
|
||||
if p.MatchString(msg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, p := range overflowPatterns {
|
||||
if p.MatchString(msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResponseHitContextLimit reports whether a *successful* response was shaped by
|
||||
// a full context window rather than by the completion budget we asked for.
|
||||
// Three shapes:
|
||||
//
|
||||
// - The provider accepted an oversized request silently and reported a prompt
|
||||
// larger than the window (z.ai).
|
||||
// - The provider truncated the input to fill the window exactly, leaving no
|
||||
// room to generate, and stopped on length with no output (Xiaomi MiMo).
|
||||
// - The provider stopped on length having emitted less than we allowed, so
|
||||
// our own cap cannot be what stopped it (the general recoverable case).
|
||||
//
|
||||
// completionBudget is the max_tokens we requested, before any context-based
|
||||
// clamping; passing the clamped value would hide exactly the case this detects.
|
||||
func ResponseHitContextLimit(
|
||||
resp *types.ChatResponse, contextWindow, completionBudget int,
|
||||
) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
prompt := resp.Usage.PromptTokens
|
||||
completion := resp.Usage.CompletionTokens
|
||||
lengthStop := isLengthStop(resp.FinishReason)
|
||||
|
||||
if contextWindow > 0 && prompt > contextWindow {
|
||||
return true
|
||||
}
|
||||
if contextWindow > 0 && lengthStop && completion == 0 && prompt > 0 &&
|
||||
prompt >= contextWindow*99/100 {
|
||||
return true
|
||||
}
|
||||
// Without usage there is nothing to compare against; treating that as
|
||||
// overflow would retry every ordinary truncation.
|
||||
return lengthStop && completionBudget > 0 && completion > 0 && completion < completionBudget
|
||||
}
|
||||
|
||||
func isLengthStop(reason string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||||
case "length", "max_tokens", "max_output_tokens":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Real error strings from the providers WeKnora talks to. An overflow read as
|
||||
// a generic failure ends the turn; read as transient it retries the identical
|
||||
// oversized request until the attempts run out.
|
||||
func TestIsOverflowError(t *testing.T) {
|
||||
overflow := []string{
|
||||
"prompt is too long: 213462 tokens > 200000 maximum",
|
||||
`413 {"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}`,
|
||||
"Your input exceeds the context window of this model",
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
|
||||
"This model's maximum prompt length is 131072 but the request contains 537812 tokens",
|
||||
"Please reduce the length of the messages or completion",
|
||||
"This endpoint's maximum context length is 131072 tokens",
|
||||
"The input (300 tokens) is longer than the model's context length (200 tokens).",
|
||||
"the request exceeds the available context size, try increasing it",
|
||||
"prompt token count of 5000 exceeds the limit of 4096",
|
||||
"invalid params, context window exceeds limit",
|
||||
"Your request exceeded model token limit: 128000",
|
||||
"Prompt has 5000 tokens, but the configured context size is 4096 tokens",
|
||||
"Range of input length should be [1, 30000]",
|
||||
"context_length_exceeded",
|
||||
}
|
||||
for _, msg := range overflow {
|
||||
assert.True(t, IsOverflowError(errors.New(msg)), "should detect overflow: %s", msg)
|
||||
}
|
||||
|
||||
// Throttling formats itself as a token complaint and is not an overflow.
|
||||
notOverflow := []string{
|
||||
"ThrottlingException: Too many tokens, please wait before trying again.",
|
||||
"429 rate limit exceeded",
|
||||
"Too many requests",
|
||||
"connection reset by peer",
|
||||
"context deadline exceeded",
|
||||
}
|
||||
for _, msg := range notOverflow {
|
||||
assert.False(t, IsOverflowError(errors.New(msg)), "should not detect overflow: %s", msg)
|
||||
}
|
||||
|
||||
assert.False(t, IsOverflowError(nil))
|
||||
}
|
||||
|
||||
func TestResponseHitContextLimit(t *testing.T) {
|
||||
const window, budget = 100000, 8000
|
||||
|
||||
// The general recoverable case: stopped on length below our own cap, so
|
||||
// our cap is not what stopped it.
|
||||
assert.True(t, ResponseHitContextLimit(&types.ChatResponse{
|
||||
FinishReason: "length",
|
||||
Usage: types.TokenUsage{PromptTokens: 90000, CompletionTokens: 2000},
|
||||
}, window, budget))
|
||||
|
||||
// Used the full budget: the model simply had more to say, and retrying
|
||||
// after a compaction would produce the same truncation.
|
||||
assert.False(t, ResponseHitContextLimit(&types.ChatResponse{
|
||||
FinishReason: "length",
|
||||
Usage: types.TokenUsage{PromptTokens: 50000, CompletionTokens: budget},
|
||||
}, window, budget))
|
||||
|
||||
// Silent overflow: the provider accepted a prompt larger than the window
|
||||
// instead of rejecting it (z.ai).
|
||||
assert.True(t, ResponseHitContextLimit(&types.ChatResponse{
|
||||
FinishReason: "stop",
|
||||
Usage: types.TokenUsage{PromptTokens: window + 1, CompletionTokens: 10},
|
||||
}, window, budget))
|
||||
|
||||
// Server truncated the input to fill the window exactly, leaving no room
|
||||
// to generate (Xiaomi MiMo).
|
||||
assert.True(t, ResponseHitContextLimit(&types.ChatResponse{
|
||||
FinishReason: "length",
|
||||
Usage: types.TokenUsage{PromptTokens: window, CompletionTokens: 0},
|
||||
}, window, budget))
|
||||
|
||||
// An ordinary completed response is not an overflow.
|
||||
assert.False(t, ResponseHitContextLimit(&types.ChatResponse{
|
||||
FinishReason: "stop",
|
||||
Usage: types.TokenUsage{PromptTokens: 1000, CompletionTokens: 100},
|
||||
}, window, budget))
|
||||
|
||||
// No usage means nothing to compare against.
|
||||
assert.False(t, ResponseHitContextLimit(&types.ChatResponse{FinishReason: "length"}, window, budget))
|
||||
assert.False(t, ResponseHitContextLimit(nil, window, budget))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agenttoken "github.com/Tencent/WeKnora/internal/agent/token"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
)
|
||||
|
||||
// The summary is injected as a `user` message rather than a second `system`
|
||||
// one. A system message is an instruction; this is conversation history, and
|
||||
// providers that merge or specially weight system messages were the wrong
|
||||
// place to put it.
|
||||
const (
|
||||
summaryPrefix = "The conversation history before this point was compacted " +
|
||||
"into the following summary:\n\n<summary>\n"
|
||||
summarySuffix = "\n</summary>"
|
||||
)
|
||||
|
||||
// Preparation is everything the summarization step needs, computed before any
|
||||
// LLM call so the caller can decide not to make one.
|
||||
type Preparation struct {
|
||||
// FirstKeptIdx is where the verbatim tail begins.
|
||||
FirstKeptIdx int
|
||||
// MessagesToSummarize are the complete turns being replaced by prose.
|
||||
MessagesToSummarize []chat.Message
|
||||
// TurnPrefixMessages is the discarded head of a split turn, summarized
|
||||
// separately so the retained tail still has its originating request.
|
||||
TurnPrefixMessages []chat.Message
|
||||
IsSplitTurn bool
|
||||
// PreviousSummary is the prior compaction's text, updated in place rather
|
||||
// than summarized again.
|
||||
PreviousSummary string
|
||||
TokensBefore int
|
||||
fileOps fileOps
|
||||
}
|
||||
|
||||
// Prepare picks the cut point and collects the message ranges around it.
|
||||
//
|
||||
// It returns nil when compaction cannot help — no messages fall outside the
|
||||
// keep-recent budget, or the last compaction already left nothing to remove.
|
||||
// That nil is the guard against the failure mode where every round spends a
|
||||
// summarization call on a context it cannot shrink.
|
||||
func Prepare(messages []chat.Message, s Settings, estimator *agenttoken.Estimator) *Preparation {
|
||||
if estimator == nil || len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
s = s.Normalize()
|
||||
|
||||
// Summarizing starts after the previous compaction's boundary. The
|
||||
// summary message itself is excluded: feeding it back in is what turns
|
||||
// successive compactions into a summary of a summary of a summary.
|
||||
boundaryStart := historyStart(messages)
|
||||
previousSummary := ""
|
||||
for i := len(messages) - 1; i >= boundaryStart; i-- {
|
||||
if messages[i].Kind != chat.MessageKindCompactionSummary {
|
||||
continue
|
||||
}
|
||||
previousSummary = unwrapSummary(messages[i].Content)
|
||||
boundaryStart = i + 1
|
||||
break
|
||||
}
|
||||
if boundaryStart >= len(messages) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cut := FindCutPoint(messages, boundaryStart, s.KeepRecentTokens, estimator)
|
||||
|
||||
historyEnd := cut.FirstKeptIdx
|
||||
if cut.IsSplitTurn {
|
||||
historyEnd = cut.TurnStartIdx
|
||||
}
|
||||
toSummarize := cloneRange(messages, boundaryStart, historyEnd)
|
||||
|
||||
var turnPrefix []chat.Message
|
||||
if cut.IsSplitTurn {
|
||||
turnPrefix = cloneRange(messages, cut.TurnStartIdx, cut.FirstKeptIdx)
|
||||
}
|
||||
|
||||
if len(toSummarize) == 0 && len(turnPrefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Preparation{
|
||||
FirstKeptIdx: cut.FirstKeptIdx,
|
||||
MessagesToSummarize: toSummarize,
|
||||
TurnPrefixMessages: turnPrefix,
|
||||
IsSplitTurn: cut.IsSplitTurn,
|
||||
PreviousSummary: previousSummary,
|
||||
TokensBefore: estimator.EstimateMessages(messages),
|
||||
fileOps: extractFileOps(previousSummary, toSummarize, turnPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rebuilds the message list as system prompt, summary, and the verbatim
|
||||
// tail. Nothing between the system prompt and the cut point survives.
|
||||
func Apply(messages []chat.Message, p *Preparation, summary string) []chat.Message {
|
||||
tailStart := min(max(p.FirstKeptIdx, 0), len(messages))
|
||||
out := make([]chat.Message, 0, 2+len(messages)-tailStart)
|
||||
if historyStart(messages) == 1 {
|
||||
out = append(out, messages[0])
|
||||
}
|
||||
out = append(out, SummaryMessage(summary))
|
||||
return append(out, messages[tailStart:]...)
|
||||
}
|
||||
|
||||
// SummaryMessage wraps summary text in the envelope the model sees, tagged so
|
||||
// the next compaction recognizes it as its own output.
|
||||
func SummaryMessage(summary string) chat.Message {
|
||||
return chat.Message{
|
||||
Role: "user",
|
||||
Kind: chat.MessageKindCompactionSummary,
|
||||
Content: summaryPrefix + summary + summarySuffix,
|
||||
}
|
||||
}
|
||||
|
||||
// unwrapSummary recovers the raw text from a summary message so it can be
|
||||
// handed to the update prompt without its envelope.
|
||||
func unwrapSummary(content string) string {
|
||||
start := strings.Index(content, "<summary>")
|
||||
if start < 0 {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
start += len("<summary>")
|
||||
end := strings.LastIndex(content, "</summary>")
|
||||
if end < start {
|
||||
return strings.TrimSpace(content[start:])
|
||||
}
|
||||
return strings.TrimSpace(content[start:end])
|
||||
}
|
||||
|
||||
func cloneRange(messages []chat.Message, start, end int) []chat.Message {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > len(messages) {
|
||||
end = len(messages)
|
||||
}
|
||||
if start >= end {
|
||||
return nil
|
||||
}
|
||||
out := make([]chat.Message, end-start)
|
||||
copy(out, messages[start:end])
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package compaction
|
||||
|
||||
// The sections are not decoration: the summary is the agent's only memory of
|
||||
// the dropped turns, and unstructured prose reliably loses the two things the
|
||||
// next round actually needs — what is already finished, and what was about to
|
||||
// happen next.
|
||||
|
||||
const summarizationSystemPrompt = "" +
|
||||
"You are a context summarization assistant. Your task is to read a conversation " +
|
||||
"between a user and an AI assistant, then produce a structured summary following " +
|
||||
"the exact format specified.\n\n" +
|
||||
"Do NOT continue the conversation. Do NOT respond to any questions in the " +
|
||||
"conversation. ONLY output the structured summary."
|
||||
|
||||
const summaryFormat = "" +
|
||||
"## Goal\n" +
|
||||
"[What the user is ultimately trying to accomplish. May be multiple items.]\n\n" +
|
||||
"## Constraints & Preferences\n" +
|
||||
"- [Requirements, formats, tools, or styles the user asked for, and anything they rejected]\n" +
|
||||
"- [Or \"(none)\"]\n\n" +
|
||||
"## Progress\n" +
|
||||
"### Done\n" +
|
||||
"- [x] [Completed work, naming the specific artifacts produced and where they were written]\n\n" +
|
||||
"### In Progress\n" +
|
||||
"- [ ] [Current work]\n\n" +
|
||||
"### Blocked\n" +
|
||||
"- [Issues preventing progress, if any]\n\n" +
|
||||
"## Key Decisions\n" +
|
||||
"- **[Decision]**: [Brief rationale, so it is not revisited]\n\n" +
|
||||
"## Next Steps\n" +
|
||||
"1. [Ordered list of what should happen next]\n\n" +
|
||||
"## Critical Context\n" +
|
||||
"- [Facts, numbers, identifiers, paths, and error messages later steps depend on]\n" +
|
||||
"- [Or \"(none)\"]\n\n" +
|
||||
"Keep each section concise. Write in the same language as the conversation. " +
|
||||
"Preserve exact values — paths, names, numbers, and error text — rather than " +
|
||||
"paraphrasing them. Output only the summary, with no preamble.\n\n" +
|
||||
"Length matters: this summary shares the context window with the retained " +
|
||||
"recent messages, so it must stay compact enough to be worth having. Aim for " +
|
||||
"under 500 words. Prefer short bullets over prose, and drop detail that later " +
|
||||
"steps cannot act on."
|
||||
|
||||
const initialSummarizationInstructions = "" +
|
||||
"The messages above are a conversation to summarize. Create a structured context " +
|
||||
"checkpoint that another LLM will use to continue the work.\n\n" +
|
||||
"Use this EXACT format:\n\n" + summaryFormat
|
||||
|
||||
const updateSummarizationInstructions = "" +
|
||||
"The messages above are NEW conversation messages to incorporate into the existing " +
|
||||
"summary provided in <previous-summary> tags.\n\n" +
|
||||
"RULES:\n" +
|
||||
"- PRESERVE information from the previous summary that later steps still need\n" +
|
||||
"- ADD new progress, decisions, and context from the new messages\n" +
|
||||
"- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n" +
|
||||
"- UPDATE \"Next Steps\" based on what was accomplished\n" +
|
||||
"- PRESERVE exact file paths, function names, and error messages\n" +
|
||||
"- CONDENSE as you go: the result must not be longer than the previous " +
|
||||
"summary unless genuinely new facts require it. Completed work collapses to " +
|
||||
"one line each; superseded plans, resolved errors, and abandoned approaches " +
|
||||
"come out entirely. An update that only ever grows defeats the compaction " +
|
||||
"it is part of.\n\n" +
|
||||
"Use this EXACT format:\n\n" + summaryFormat
|
||||
|
||||
// turnPrefixInstructions summarizes the discarded head of a split turn. It is
|
||||
// a different job from the history summary: the retained suffix is still in
|
||||
// context, so this only has to supply what the suffix cannot explain about
|
||||
// itself — above all, what the user originally asked for.
|
||||
const turnPrefixInstructions = "" +
|
||||
"This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) " +
|
||||
"is retained in the conversation and is NOT shown here.\n\n" +
|
||||
"Summarize the prefix to provide context for the retained suffix, using this EXACT " +
|
||||
"format:\n\n" +
|
||||
"## Original Request\n" +
|
||||
"[What did the user ask for in this turn?]\n\n" +
|
||||
"## Early Progress\n" +
|
||||
"- [Key decisions and work done in the prefix, naming artifacts and paths]\n\n" +
|
||||
"## Context for Suffix\n" +
|
||||
"- [Information needed to understand the retained recent work]\n\n" +
|
||||
"Be concise. Write in the same language as the conversation. Preserve exact file " +
|
||||
"paths, names, and error messages. Output only the summary."
|
||||
|
||||
const splitTurnSeparator = "\n\n---\n\n**Turn Context (split turn):**\n\n"
|
||||
@@ -0,0 +1,128 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
)
|
||||
|
||||
const (
|
||||
// toolResultMaxChars caps a single tool result inside the summarization
|
||||
// request. Tool output is the largest contributor to context size and the
|
||||
// summary needs its gist, not its bytes.
|
||||
toolResultMaxChars = 2000
|
||||
// textMaxChars caps user and assistant prose, which is rarely the problem
|
||||
// but should not be unbounded either.
|
||||
textMaxChars = 4000
|
||||
// toolArgsMaxChars caps rendered tool-call arguments. A write_sandbox_file
|
||||
// call carries an entire file body in its arguments; the summary needs the
|
||||
// path and the fact of the write, never the content.
|
||||
toolArgsMaxChars = 400
|
||||
)
|
||||
|
||||
// serializeConversation renders messages as a transcript rather than passing
|
||||
// them as a conversation. A model handed real messages tries to continue them;
|
||||
// handed a transcript, it summarizes them.
|
||||
func serializeConversation(messages []chat.Message) string {
|
||||
var parts []string
|
||||
for i := range messages {
|
||||
msg := &messages[i]
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
continue
|
||||
case "user":
|
||||
if content := truncate(msg.Content, textMaxChars); content != "" {
|
||||
parts = append(parts, "[User]: "+content)
|
||||
}
|
||||
case "assistant":
|
||||
if msg.ReasoningContent != "" {
|
||||
parts = append(parts,
|
||||
"[Assistant thinking]: "+truncate(msg.ReasoningContent, textMaxChars))
|
||||
}
|
||||
if msg.Content != "" {
|
||||
parts = append(parts, "[Assistant]: "+truncate(msg.Content, textMaxChars))
|
||||
}
|
||||
if calls := serializeToolCalls(msg.ToolCalls); calls != "" {
|
||||
parts = append(parts, "[Assistant tool calls]: "+calls)
|
||||
}
|
||||
case "tool":
|
||||
if content := truncate(msg.Content, toolResultMaxChars); content != "" {
|
||||
parts = append(parts, fmt.Sprintf("[Tool result %s]: %s", msg.Name, content))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func serializeToolCalls(calls []chat.ToolCall) string {
|
||||
if len(calls) == 0 {
|
||||
return ""
|
||||
}
|
||||
rendered := make([]string, 0, len(calls))
|
||||
for _, tc := range calls {
|
||||
rendered = append(rendered,
|
||||
fmt.Sprintf("%s(%s)", tc.Function.Name, renderToolArgs(tc.Function.Arguments)))
|
||||
}
|
||||
return strings.Join(rendered, "; ")
|
||||
}
|
||||
|
||||
// renderToolArgs turns an arguments JSON blob into `key=value` pairs, dropping
|
||||
// oversized values. Keys are sorted so the same call always renders the same
|
||||
// way, which matters when the transcript is compared across compactions.
|
||||
func renderToolArgs(arguments string) string {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(arguments), &parsed); err != nil {
|
||||
return truncate(arguments, toolArgsMaxChars)
|
||||
}
|
||||
keys := make([]string, 0, len(parsed))
|
||||
for k := range parsed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
encoded, err := json.Marshal(parsed[k])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", k, truncate(string(encoded), toolArgsMaxChars)))
|
||||
}
|
||||
return strings.Join(pairs, ", ")
|
||||
}
|
||||
|
||||
func truncate(s string, maxChars int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxChars {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%s\n\n[... %d more characters truncated]",
|
||||
string(runes[:maxChars]), len(runes)-maxChars)
|
||||
}
|
||||
|
||||
// rawArchive is the fallback when the summarizer is unavailable. It is lossy
|
||||
// and unstructured, but it keeps the tool names and paths that the next round
|
||||
// needs in order not to redo finished work.
|
||||
func rawArchive(messages []chat.Message) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Raw conversation archive (LLM summarization unavailable):\n\n")
|
||||
for i := range messages {
|
||||
msg := &messages[i]
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
fmt.Fprintf(&sb, "- User: %s\n", truncate(msg.Content, 500))
|
||||
case "assistant":
|
||||
if calls := serializeToolCalls(msg.ToolCalls); calls != "" {
|
||||
fmt.Fprintf(&sb, "- Assistant [%s]: %s\n", calls, truncate(msg.Content, 500))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&sb, "- Assistant: %s\n", truncate(msg.Content, 500))
|
||||
case "tool":
|
||||
fmt.Fprintf(&sb, "- Tool[%s]: %s\n", msg.Name, truncate(msg.Content, 500))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package compaction keeps an agent turn inside the model's context window by
|
||||
// replacing older conversation with a structured summary.
|
||||
//
|
||||
// The cut point is chosen by token budget alone — never by "keep the current
|
||||
// turn intact". A ReAct loop is a single turn that can run twenty rounds and
|
||||
// produce a hundred thousand tokens of tool traffic; a compactor that refuses
|
||||
// to touch the current turn has nothing left to compact and burns an LLM call
|
||||
// per round achieving nothing.
|
||||
package compaction
|
||||
|
||||
// DefaultReserveTokens is the floor on room kept free for the next response.
|
||||
const DefaultReserveTokens = 16384
|
||||
|
||||
// DefaultKeepRecentTokens is how much recent conversation survives a
|
||||
// compaction untouched. It is what makes compaction terminate: whatever the
|
||||
// context grew to, it comes out of a compaction at roughly summary + this,
|
||||
// which is far below any sane threshold, so the next round cannot immediately
|
||||
// re-trigger.
|
||||
const DefaultKeepRecentTokens = 20000
|
||||
|
||||
// Settings configures when compaction runs and how much it keeps.
|
||||
type Settings struct {
|
||||
// Enabled turns automatic compaction on. Disabled still allows an
|
||||
// explicit overflow-recovery compaction, which is a repair, not a policy.
|
||||
Enabled bool
|
||||
// MaxContextTokens is the model's context window. Zero disables
|
||||
// compaction entirely: without a window there is no budget to enforce.
|
||||
MaxContextTokens int
|
||||
// ReserveTokens is the part of the window history may not occupy because
|
||||
// the next response has to fit there.
|
||||
ReserveTokens int
|
||||
// KeepRecentTokens is the token budget of recent messages kept verbatim.
|
||||
KeepRecentTokens int
|
||||
// MaxSummaryTokens caps the summarization completion. Zero falls back to
|
||||
// the reserve-derived budget alone.
|
||||
MaxSummaryTokens int
|
||||
}
|
||||
|
||||
// Normalize fills in defaults and reconciles budgets that cannot all be
|
||||
// honored at once.
|
||||
func (s Settings) Normalize() Settings {
|
||||
if s.ReserveTokens <= 0 {
|
||||
s.ReserveTokens = DefaultReserveTokens
|
||||
}
|
||||
if s.KeepRecentTokens <= 0 {
|
||||
s.KeepRecentTokens = DefaultKeepRecentTokens
|
||||
}
|
||||
// What matters is not how much a compaction frees but how much room it
|
||||
// leaves before the next one. Keeping half the usable window technically
|
||||
// compacts and still re-triggers within a round or two, because the
|
||||
// summary takes a further slice and a single tool result can be ten
|
||||
// thousand tokens. A quarter leaves the post-compaction context at roughly
|
||||
// half the threshold, which is several rounds of work.
|
||||
//
|
||||
// Large windows are unaffected: the default keep-recent budget is already
|
||||
// well under a quarter of them. Tool schemas are not subtracted here —
|
||||
// keepRecentTokens is a fixed conversation budget, not "whatever is left
|
||||
// after the tool list".
|
||||
if s.MaxContextTokens > 0 {
|
||||
usable := s.MaxContextTokens - s.ReserveTokens
|
||||
if quarter := usable / 4; quarter > 0 && s.KeepRecentTokens > quarter {
|
||||
s.KeepRecentTokens = quarter
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Threshold is the context size above which history must be compacted. It is
|
||||
// an absolute reserve rather than a fraction of the window, because what has
|
||||
// to fit is the response, whose size does not scale with the window.
|
||||
func (s Settings) Threshold() int {
|
||||
if s.MaxContextTokens <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(s.MaxContextTokens-s.ReserveTokens, 1)
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether the current context has crossed the threshold.
|
||||
func (s Settings) ShouldCompact(contextTokens int) bool {
|
||||
if !s.Enabled {
|
||||
return false
|
||||
}
|
||||
threshold := s.Threshold()
|
||||
return threshold > 0 && contextTokens > threshold
|
||||
}
|
||||
|
||||
// summaryBudget is the completion cap for a summarization call: 0.8 of
|
||||
// reserveTokens, clamped by the model's own output limit. The old hard-coded
|
||||
// 2000 was the reason long sessions produced summaries that stopped
|
||||
// mid-sentence.
|
||||
//
|
||||
// It is additionally capped at the keep-recent budget. The summary occupies the
|
||||
// compacted context alongside the retained tail, and successive compactions
|
||||
// update the summary in place under instructions to preserve what is already
|
||||
// there — so without a ceiling tied to the same budget it grows every round and
|
||||
// slowly reclaims the room compaction just freed.
|
||||
func (s Settings) summaryBudget() int {
|
||||
budget := s.ReserveTokens * 4 / 5
|
||||
if s.MaxSummaryTokens > 0 && s.MaxSummaryTokens < budget {
|
||||
budget = s.MaxSummaryTokens
|
||||
}
|
||||
if s.KeepRecentTokens > 0 && s.KeepRecentTokens < budget {
|
||||
budget = s.KeepRecentTokens
|
||||
}
|
||||
return max(budget, 1024)
|
||||
}
|
||||
|
||||
// turnPrefixBudget is the smaller cap for summarizing the discarded prefix of
|
||||
// a split turn: it only has to explain the retained suffix, not the session.
|
||||
func (s Settings) turnPrefixBudget() int {
|
||||
return max(s.summaryBudget()/2, 512)
|
||||
}
|
||||
Reference in New Issue
Block a user