mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
refactor(chatd): replace fantasy.Agent with custom agent loop (#22507)
## Summary Replaces fantasy's `Agent` abstraction with a direct step loop calling `LanguageModel.Stream()`. Fantasy is retained as the provider abstraction layer (streaming parsers, types, tool schema) but we no longer use `fantasy.Agent`, `AgentStreamCall`, `AgentResult`, or `StepResult`. ## Problems solved | Problem | Before | After | |---|---|---| | **Sentinel prompt hack** | fantasy.Agent requires non-empty Prompt → UUID sentinel generated and stripped in PrepareStep | Messages passed directly to `model.Stream()` | | **Discarded PersistStep errors** | `_ = opts.OnStepFinish(result)` silently swallows errors | Errors propagate directly from `PersistStep()` | | **Shadow draft state** | ~160 LOC tracking content in parallel because fantasy doesn't expose in-progress content on interruption | `stepResult` owns content directly; `flushActiveState()` is trivial | | **Nested retry layers** | fantasy's 2-attempt retry nested inside chatretry's indefinite retry | Single `chatretry.Retry` layer | | **Callback-mediated compaction** | Mutex + boolean flag + coordination between OnStepFinish/PrepareStep callbacks | Inline `if` statement between steps | | **Duplicate compaction paths** | `compactStep()` + `maybeCompact()` sharing ~80% logic | Single `tryCompact()` function | ## Changes ### `coderd/chatd/chatloop/chatloop.go` — Rewritten - **Removed**: `fantasy.NewAgent()`, `AgentStreamCall`, sentinel prompt, shadow draft state (~160 LOC of closures), `compactedMu`/`compacted` flag, `PrepareStepResult` - **Added**: `stepResult` struct, `processStepStream()` (stream consumer), `executeTools()` (sequential tool execution), `flushActiveState()` (interrupt handling), `buildToolDefinitions()`, `toResponseMessages()` - **Changed**: `Run()` return type from `(*fantasy.AgentResult, error)` to `error` (callers already discarded the result) - **Preserved**: Anthropic prompt caching, reasoning title extraction, `extractContextLimit()`, `ErrInterrupted` semantics ### `coderd/chatd/chatloop/compaction.go` — Simplified - Merged `compactStep()` + `maybeCompact()` → single `tryCompact()` - Removed `[]StepResult` parameter from `generateCompactionSummary()` (caller provides complete message list) - Kept helper functions: `normalizedCompactionConfig`, `contextTokensFromUsage`, `resolveContextLimit`, `shouldCompact` ### `coderd/chatd/chatd.go` — Caller updates - Removed `AgentStreamCall` construction - Changed `_, err = chatloop.Run(...)` to `err = chatloop.Run(...)` - Model parameters moved from `AgentStreamCall` fields to `RunOptions` fields ### Tests — 4 new tests - `MidLoopCompactionReloadsMessages` — compaction fires mid-loop, messages reloaded - `PostRunCompactionSkippedAfterMidLoop` — no double compaction - `MultiStepToolExecution` — tools execute between steps, results feed next step - `PersistStepErrorPropagates` — persistence errors propagate (was silently discarded)
This commit is contained in:
+21
-43
@@ -2036,28 +2036,19 @@ func (p *Server) runChat(
|
||||
return nil
|
||||
}
|
||||
|
||||
streamCall := fantasy.AgentStreamCall{
|
||||
MaxOutputTokens: callConfig.MaxOutputTokens,
|
||||
Temperature: callConfig.Temperature,
|
||||
TopP: callConfig.TopP,
|
||||
TopK: callConfig.TopK,
|
||||
PresencePenalty: callConfig.PresencePenalty,
|
||||
FrequencyPenalty: callConfig.FrequencyPenalty,
|
||||
ProviderOptions: chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions),
|
||||
}
|
||||
|
||||
if streamCall.MaxOutputTokens == nil {
|
||||
// Apply the default MaxOutputTokens if the model config
|
||||
// does not specify one.
|
||||
if callConfig.MaxOutputTokens == nil {
|
||||
maxOutputTokens := int64(32_000)
|
||||
streamCall.MaxOutputTokens = &maxOutputTokens
|
||||
callConfig.MaxOutputTokens = &maxOutputTokens
|
||||
}
|
||||
|
||||
// Generate the tool call ID up front so that the OnStart
|
||||
// streaming part and the Persist durable messages share
|
||||
// the same identifier. Without this the client cannot
|
||||
// correlate the "Summarizing..." tool call with the
|
||||
// "Summarized" tool result.
|
||||
// Generate the tool call ID up front so that the streaming
|
||||
// parts and durable messages share the same identifier.
|
||||
// Without this the client cannot correlate the
|
||||
// "Summarizing..." tool call with the "Summarized" tool
|
||||
// result.
|
||||
compactionToolCallID := "chat_summarized_" + uuid.NewString()
|
||||
|
||||
compactionOptions := &chatloop.CompactionOptions{
|
||||
ThresholdPercent: modelConfig.CompressionThreshold,
|
||||
ContextLimit: modelConfig.ContextLimit,
|
||||
@@ -2083,15 +2074,10 @@ func (p *Server) runChat(
|
||||
)
|
||||
return nil
|
||||
},
|
||||
OnStart: func() {
|
||||
// Publish a streaming tool-call part immediately so
|
||||
// connected clients see "Summarizing..." while the
|
||||
// LLM generates the summary.
|
||||
p.publishMessagePart(chat.ID, string(fantasy.MessageRoleAssistant), codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolCall,
|
||||
ToolCallID: compactionToolCallID,
|
||||
ToolName: "chat_summarized",
|
||||
})
|
||||
ToolCallID: compactionToolCallID,
|
||||
ToolName: "chat_summarized",
|
||||
PublishMessagePart: func(role fantasy.MessageRole, part codersdk.ChatMessagePart) {
|
||||
p.publishMessagePart(chat.ID, string(role), part)
|
||||
},
|
||||
OnError: func(err error) {
|
||||
logger.Warn(ctx, "failed to compact chat context", slog.Error(err))
|
||||
@@ -2147,12 +2133,14 @@ func (p *Server) runChat(
|
||||
})...)
|
||||
}
|
||||
|
||||
_, err = chatloop.Run(ctx, chatloop.RunOptions{
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools,
|
||||
StreamCall: streamCall,
|
||||
MaxSteps: maxChatSteps,
|
||||
err = chatloop.Run(ctx, chatloop.RunOptions{
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools,
|
||||
MaxSteps: maxChatSteps,
|
||||
|
||||
ModelConfig: callConfig,
|
||||
ProviderOptions: chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions),
|
||||
|
||||
ContextLimitFallback: modelConfigContextLimit,
|
||||
|
||||
@@ -2315,16 +2303,6 @@ func (p *Server) persistChatContextSummary(
|
||||
return xerrors.Errorf("insert summary tool result message: %w", err)
|
||||
}
|
||||
|
||||
// Publish a streaming tool-result part so connected clients
|
||||
// transition from "Summarizing..." to "Summarized" before the
|
||||
// durable messages and status change arrive.
|
||||
p.publishMessagePart(chatID, string(fantasy.MessageRoleTool), codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolResult,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: "chat_summarized",
|
||||
Result: summaryResult,
|
||||
})
|
||||
|
||||
p.publishMessage(chatID, assistantMessage)
|
||||
p.publishMessage(chatID, toolMessage)
|
||||
return nil
|
||||
|
||||
+677
-430
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"iter"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -34,7 +35,7 @@ func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
|
||||
persistStepCalls := 0
|
||||
var persistedStep PersistedStep
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "sys-1"),
|
||||
@@ -130,7 +131,7 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
|
||||
persistedAssistantCtxErr := xerrors.New("unset")
|
||||
var persistedContent []fantasy.Content
|
||||
|
||||
_, err := Run(ctx, RunOptions{
|
||||
err := Run(ctx, RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
@@ -274,6 +275,136 @@ func containsPromptSentinel(prompt []fantasy.Message) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRun_MultiStepToolExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCalls int
|
||||
var secondCallPrompt []fantasy.Message
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCalls
|
||||
streamCalls++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: produce a tool call.
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{"path":"main.go"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{"path":"main.go"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: capture the prompt the loop sent us,
|
||||
// then return plain text.
|
||||
mu.Lock()
|
||||
secondCallPrompt = append([]fantasy.Message(nil), call.Prompt...)
|
||||
mu.Unlock()
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "all done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var persistStepCalls int
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "please read main.go"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
persistStepCalls++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stream was called twice: once for the tool-call step,
|
||||
// once for the follow-up text step.
|
||||
require.Equal(t, 2, streamCalls)
|
||||
|
||||
// PersistStep is called once per step.
|
||||
require.Equal(t, 2, persistStepCalls)
|
||||
|
||||
// The second call's prompt must contain the assistant message
|
||||
// from step 0 (with the tool call) and a tool-result message.
|
||||
require.NotEmpty(t, secondCallPrompt)
|
||||
|
||||
var foundAssistantToolCall bool
|
||||
var foundToolResult bool
|
||||
for _, msg := range secondCallPrompt {
|
||||
if msg.Role == fantasy.MessageRoleAssistant {
|
||||
for _, part := range msg.Content {
|
||||
if tc, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part); ok {
|
||||
if tc.ToolCallID == "tc-1" && tc.ToolName == "read_file" {
|
||||
foundAssistantToolCall = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.Role == fantasy.MessageRoleTool {
|
||||
for _, part := range msg.Content {
|
||||
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
||||
if tr.ToolCallID == "tc-1" {
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundAssistantToolCall, "second call prompt should contain assistant tool call from step 0")
|
||||
require.True(t, foundToolResult, "second call prompt should contain tool result message")
|
||||
}
|
||||
|
||||
func TestRun_PersistStepErrorPropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "hello"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
persistErr := xerrors.New("database write failed")
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return persistErr
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "database write failed")
|
||||
}
|
||||
|
||||
func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool {
|
||||
if len(message.ProviderOptions) == 0 {
|
||||
return false
|
||||
|
||||
+213
-109
@@ -2,11 +2,14 @@ package chatloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,8 +33,18 @@ type CompactionOptions struct {
|
||||
SystemSummaryPrefix string
|
||||
Timeout time.Duration
|
||||
Persist func(context.Context, CompactionResult) error
|
||||
OnStart func()
|
||||
OnError func(error)
|
||||
|
||||
// ToolCallID and ToolName identify the synthetic tool call
|
||||
// used to represent compaction in the message stream.
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
|
||||
// PublishMessagePart publishes streaming parts to connected
|
||||
// clients so they see "Summarizing..." / "Summarized" UI
|
||||
// transitions during compaction.
|
||||
PublishMessagePart func(fantasy.MessageRole, codersdk.ChatMessagePart)
|
||||
|
||||
OnError func(error)
|
||||
}
|
||||
|
||||
type CompactionResult struct {
|
||||
@@ -43,18 +56,145 @@ type CompactionResult struct {
|
||||
ContextLimit int64
|
||||
}
|
||||
|
||||
func maybeCompact(
|
||||
// tryCompact checks whether context usage exceeds the compaction
|
||||
// threshold and, if so, generates and persists a summary. Returns
|
||||
// (true, nil) when compaction was performed, (false, nil) when not
|
||||
// needed, and (false, err) on failure.
|
||||
func tryCompact(
|
||||
ctx context.Context,
|
||||
runOpts RunOptions,
|
||||
runResult *fantasy.AgentResult,
|
||||
) error {
|
||||
if runResult == nil || runOpts.Compaction == nil {
|
||||
return nil
|
||||
model fantasy.LanguageModel,
|
||||
compaction *CompactionOptions,
|
||||
contextLimitFallback int64,
|
||||
stepUsage fantasy.Usage,
|
||||
stepMetadata fantasy.ProviderMetadata,
|
||||
allMessages []fantasy.Message,
|
||||
) (bool, error) {
|
||||
config, ok := normalizedCompactionConfig(compaction)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
config := *runOpts.Compaction
|
||||
contextTokens := contextTokensFromUsage(stepUsage)
|
||||
if contextTokens <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
metadataLimit := extractContextLimit(stepMetadata)
|
||||
contextLimit := resolveContextLimit(
|
||||
metadataLimit.Int64,
|
||||
config.ContextLimit,
|
||||
contextLimitFallback,
|
||||
)
|
||||
|
||||
usagePercent, compact := shouldCompact(
|
||||
contextTokens, contextLimit, config.ThresholdPercent,
|
||||
)
|
||||
if !compact {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Publish the "Summarizing..." tool-call indicator so
|
||||
// connected clients see activity during summary generation.
|
||||
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
||||
config.PublishMessagePart(
|
||||
fantasy.MessageRoleAssistant,
|
||||
codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolCall,
|
||||
ToolCallID: config.ToolCallID,
|
||||
ToolName: config.ToolName,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
summary, err := generateCompactionSummary(
|
||||
ctx, model, allMessages, config,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if summary == "" {
|
||||
// Publish a tool-result error so connected clients
|
||||
// see the compaction failure.
|
||||
publishCompactionError(config, "compaction produced an empty summary")
|
||||
return false, xerrors.New("compaction produced an empty summary")
|
||||
}
|
||||
|
||||
systemSummary := strings.TrimSpace(
|
||||
config.SystemSummaryPrefix + "\n\n" + summary,
|
||||
)
|
||||
|
||||
err = config.Persist(ctx, CompactionResult{
|
||||
SystemSummary: systemSummary,
|
||||
SummaryReport: summary,
|
||||
ThresholdPercent: config.ThresholdPercent,
|
||||
UsagePercent: usagePercent,
|
||||
ContextTokens: contextTokens,
|
||||
ContextLimit: contextLimit,
|
||||
})
|
||||
if err != nil {
|
||||
publishCompactionError(config, "failed to persist compaction result")
|
||||
return false, xerrors.Errorf("persist compaction: %w", err)
|
||||
}
|
||||
|
||||
// Publish the "Summarized" tool-result part so the client
|
||||
// transitions from the in-progress indicator to the final
|
||||
// state.
|
||||
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
||||
resultJSON, _ := json.Marshal(map[string]any{
|
||||
"summary": summary,
|
||||
"source": "automatic",
|
||||
"threshold_percent": config.ThresholdPercent,
|
||||
"usage_percent": usagePercent,
|
||||
"context_tokens": contextTokens,
|
||||
"context_limit_tokens": contextLimit,
|
||||
})
|
||||
config.PublishMessagePart(
|
||||
fantasy.MessageRoleTool,
|
||||
codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolResult,
|
||||
ToolCallID: config.ToolCallID,
|
||||
ToolName: config.ToolName,
|
||||
Result: resultJSON,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// publishCompactionError sends a tool-result error part so
|
||||
// connected clients see that compaction failed.
|
||||
func publishCompactionError(config CompactionOptions, msg string) {
|
||||
if config.PublishMessagePart == nil || config.ToolCallID == "" {
|
||||
return
|
||||
}
|
||||
errJSON, _ := json.Marshal(map[string]any{
|
||||
"error": msg,
|
||||
})
|
||||
config.PublishMessagePart(
|
||||
fantasy.MessageRoleTool,
|
||||
codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolResult,
|
||||
ToolCallID: config.ToolCallID,
|
||||
ToolName: config.ToolName,
|
||||
Result: errJSON,
|
||||
IsError: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// normalizedCompactionConfig returns a copy of the compaction options
|
||||
// with defaults applied. The bool is false when compaction is
|
||||
// disabled (nil options, missing Persist callback, or threshold at
|
||||
// 100%).
|
||||
func normalizedCompactionConfig(opts *CompactionOptions) (CompactionOptions, bool) {
|
||||
if opts == nil {
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
|
||||
config := *opts
|
||||
if config.Persist == nil {
|
||||
return xerrors.New("compaction persist callback is required")
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
if strings.TrimSpace(config.SummaryPrompt) == "" {
|
||||
config.SummaryPrompt = defaultCompactionSummaryPrompt
|
||||
@@ -69,116 +209,80 @@ func maybeCompact(
|
||||
config.ThresholdPercent > maxCompactionThresholdPercent {
|
||||
config.ThresholdPercent = defaultCompactionThresholdPercent
|
||||
}
|
||||
|
||||
if config.ThresholdPercent >= maxCompactionThresholdPercent {
|
||||
return nil
|
||||
}
|
||||
if runOpts.MaxSteps > 0 && len(runResult.Steps) >= runOpts.MaxSteps {
|
||||
lastStep := runResult.Steps[len(runResult.Steps)-1]
|
||||
if lastStep.FinishReason == fantasy.FinishReasonToolCalls &&
|
||||
len(lastStep.Content.ToolCalls()) > 0 {
|
||||
return nil
|
||||
}
|
||||
if config.ThresholdPercent == maxCompactionThresholdPercent {
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
|
||||
contextTokens := int64(0)
|
||||
contextLimitFromMetadata := int64(0)
|
||||
for i := len(runResult.Steps) - 1; i >= 0; i-- {
|
||||
usage := runResult.Steps[i].Usage
|
||||
total := int64(0)
|
||||
hasContextTokens := false
|
||||
|
||||
if usage.InputTokens > 0 {
|
||||
total += usage.InputTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheReadTokens > 0 {
|
||||
total += usage.CacheReadTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheCreationTokens > 0 {
|
||||
total += usage.CacheCreationTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if !hasContextTokens && usage.TotalTokens > 0 {
|
||||
total = usage.TotalTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if !hasContextTokens || total <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
contextTokens = total
|
||||
metadataLimit := extractContextLimit(runResult.Steps[i].ProviderMetadata)
|
||||
if metadataLimit.Valid && metadataLimit.Int64 > 0 {
|
||||
contextLimitFromMetadata = metadataLimit.Int64
|
||||
}
|
||||
break
|
||||
}
|
||||
if contextTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
contextLimit := contextLimitFromMetadata
|
||||
if contextLimit <= 0 && config.ContextLimit > 0 {
|
||||
contextLimit = config.ContextLimit
|
||||
}
|
||||
if contextLimit <= 0 && runOpts.ContextLimitFallback > 0 {
|
||||
contextLimit = runOpts.ContextLimitFallback
|
||||
}
|
||||
if contextLimit <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
||||
if usagePercent < float64(config.ThresholdPercent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.OnStart != nil {
|
||||
config.OnStart()
|
||||
}
|
||||
|
||||
summary, err := generateCompactionSummary(
|
||||
ctx,
|
||||
runOpts.Model,
|
||||
runOpts.Messages,
|
||||
runResult.Steps,
|
||||
config,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
systemSummary := strings.TrimSpace(
|
||||
config.SystemSummaryPrefix + "\n\n" + summary,
|
||||
)
|
||||
|
||||
return config.Persist(ctx, CompactionResult{
|
||||
SystemSummary: systemSummary,
|
||||
SummaryReport: summary,
|
||||
ThresholdPercent: config.ThresholdPercent,
|
||||
UsagePercent: usagePercent,
|
||||
ContextTokens: contextTokens,
|
||||
ContextLimit: contextLimit,
|
||||
})
|
||||
return config, true
|
||||
}
|
||||
|
||||
// contextTokensFromUsage returns the total context token count from
|
||||
// a step's usage report. It sums input, cache-read, and
|
||||
// cache-creation tokens when available, falling back to TotalTokens
|
||||
// if none of the granular fields are set.
|
||||
func contextTokensFromUsage(usage fantasy.Usage) int64 {
|
||||
total := int64(0)
|
||||
hasContextTokens := false
|
||||
|
||||
if usage.InputTokens > 0 {
|
||||
total += usage.InputTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheReadTokens > 0 {
|
||||
total += usage.CacheReadTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheCreationTokens > 0 {
|
||||
total += usage.CacheCreationTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if !hasContextTokens && usage.TotalTokens > 0 {
|
||||
total = usage.TotalTokens
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// resolveContextLimit picks the first positive value from metadata,
|
||||
// configured limit, and fallback — in that priority order. Returns
|
||||
// 0 when none are positive.
|
||||
func resolveContextLimit(metadataLimit, configLimit, fallback int64) int64 {
|
||||
if metadataLimit > 0 {
|
||||
return metadataLimit
|
||||
}
|
||||
if configLimit > 0 {
|
||||
return configLimit
|
||||
}
|
||||
if fallback > 0 {
|
||||
return fallback
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// shouldCompact returns the usage percentage and whether it exceeds
|
||||
// the threshold. Returns (0, false) when contextLimit is
|
||||
// non-positive.
|
||||
func shouldCompact(contextTokens, contextLimit int64, thresholdPercent int32) (float64, bool) {
|
||||
if contextLimit <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
||||
return usagePercent, usagePercent >= float64(thresholdPercent)
|
||||
}
|
||||
|
||||
// generateCompactionSummary asks the model to summarize the
|
||||
// conversation so far. The provided messages should contain the
|
||||
// complete history (system prompt, user/assistant turns, tool
|
||||
// results). A final user message with the summary prompt is appended
|
||||
// before calling the model.
|
||||
func generateCompactionSummary(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
steps []fantasy.StepResult,
|
||||
options CompactionOptions,
|
||||
) (string, error) {
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+len(steps)+1)
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+1)
|
||||
summaryPrompt = append(summaryPrompt, messages...)
|
||||
for _, step := range steps {
|
||||
summaryPrompt = append(summaryPrompt, step.Messages...)
|
||||
}
|
||||
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
|
||||
@@ -2,11 +2,14 @@ package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestRun_Compaction(t *testing.T) {
|
||||
@@ -54,7 +57,7 @@ func TestRun_Compaction(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
@@ -83,14 +86,14 @@ func TestRun_Compaction(t *testing.T) {
|
||||
require.InDelta(t, 80.0, persistedCompaction.UsagePercent, 0.0001)
|
||||
})
|
||||
|
||||
t.Run("OnStartFiresBeforePersist", func(t *testing.T) {
|
||||
t.Run("PublishesPartsBeforeAndAfterPersist", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const summaryText = "compaction summary for ordering test"
|
||||
|
||||
// Track the order of callbacks to verify OnStart fires
|
||||
// before the Generate call (summary generation) and
|
||||
// before Persist.
|
||||
// Track the order of callbacks to verify the tool-call
|
||||
// part publishes before Generate (summary generation)
|
||||
// and the tool-result part publishes after Persist.
|
||||
var callOrder []string
|
||||
|
||||
model := &loopTestModel{
|
||||
@@ -120,7 +123,7 @@ func TestRun_Compaction(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
@@ -133,8 +136,15 @@ func TestRun_Compaction(t *testing.T) {
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
OnStart: func() {
|
||||
callOrder = append(callOrder, "on_start")
|
||||
ToolCallID: "test-tool-call-id",
|
||||
ToolName: "chat_summarized",
|
||||
PublishMessagePart: func(role fantasy.MessageRole, part codersdk.ChatMessagePart) {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeToolCall:
|
||||
callOrder = append(callOrder, "publish_tool_call")
|
||||
case codersdk.ChatMessagePartTypeToolResult:
|
||||
callOrder = append(callOrder, "publish_tool_result")
|
||||
}
|
||||
},
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
callOrder = append(callOrder, "persist")
|
||||
@@ -143,13 +153,18 @@ func TestRun_Compaction(t *testing.T) {
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"on_start", "generate", "persist"}, callOrder)
|
||||
require.Equal(t, []string{
|
||||
"publish_tool_call",
|
||||
"generate",
|
||||
"persist",
|
||||
"publish_tool_result",
|
||||
}, callOrder)
|
||||
})
|
||||
|
||||
t.Run("OnStartNotCalledBelowThreshold", func(t *testing.T) {
|
||||
t.Run("PublishNotCalledBelowThreshold", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
onStartCalled := false
|
||||
publishCalled := false
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
@@ -166,7 +181,7 @@ func TestRun_Compaction(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
@@ -178,8 +193,10 @@ func TestRun_Compaction(t *testing.T) {
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
OnStart: func() {
|
||||
onStartCalled = true
|
||||
ToolCallID: "test-tool-call-id",
|
||||
ToolName: "chat_summarized",
|
||||
PublishMessagePart: func(_ fantasy.MessageRole, _ codersdk.ChatMessagePart) {
|
||||
publishCalled = true
|
||||
},
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
return nil
|
||||
@@ -187,7 +204,216 @@ func TestRun_Compaction(t *testing.T) {
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, onStartCalled, "OnStart should not fire when usage is below threshold")
|
||||
require.False(t, publishCalled, "PublishMessagePart should not fire when usage is below threshold")
|
||||
})
|
||||
|
||||
t.Run("MidLoopCompactionReloadsMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
persistCompactionCalls := 0
|
||||
reloadCalls := 0
|
||||
|
||||
const summaryText = "compacted summary"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: tool call with high usage (80/100 = 80% > 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonToolCalls,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: text with low usage (30/100 = 30% < 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 30,
|
||||
TotalTokens: 35,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "compacted system"),
|
||||
textMessage(fantasy.MessageRoleUser, "compacted user"),
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
reloadCalls++
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compaction fired after step 0 (above threshold).
|
||||
require.GreaterOrEqual(t, persistCompactionCalls, 1)
|
||||
// ReloadMessages was called after mid-loop compaction.
|
||||
require.GreaterOrEqual(t, reloadCalls, 1)
|
||||
// Both steps ran (tool-call step + follow-up text step).
|
||||
require.Equal(t, 2, streamCallCount)
|
||||
})
|
||||
|
||||
t.Run("PostRunCompactionSkippedAfterMidLoop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
persistCompactionCalls := 0
|
||||
|
||||
const summaryText = "compacted summary for skip test"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: tool call with high usage (80/100 = 80% > 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonToolCalls,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: text with low usage (20/100 = 20% < 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 20,
|
||||
TotalTokens: 25,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "compacted system"),
|
||||
textMessage(fantasy.MessageRoleUser, "compacted user"),
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only mid-loop compaction fires after step 0. The post-run
|
||||
// safety net is skipped because alreadyCompacted is true.
|
||||
require.Equal(t, 1, persistCompactionCalls)
|
||||
})
|
||||
|
||||
t.Run("ErrorsAreReported", func(t *testing.T) {
|
||||
@@ -212,7 +438,7 @@ func TestRun_Compaction(t *testing.T) {
|
||||
}
|
||||
|
||||
compactionErr := xerrors.New("unset")
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
|
||||
Reference in New Issue
Block a user