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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -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
}
+246
View File
@@ -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")
}
+118
View File
@@ -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
}
+181
View File
@@ -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
}
+90
View File
@@ -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)
}
+116
View File
@@ -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))
}
+145
View File
@@ -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
}
+82
View File
@@ -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"
+128
View File
@@ -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()
}
+112
View File
@@ -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)
}