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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ assistant:
base_delay: 2s
max_delay: 30s

context:
preflight_enabled: true
# 0 uses the selected model max_tokens, otherwise 16k capped at 20% of context.
output_reserve_tokens: 0
provider_reserve_tokens: 2048
safety_margin_tokens: 8192
# Recent history retained verbatim during /compact; reserves above still apply to provider requests.
keep_recent_tokens: 20000

cache:
enabled: true
capacity: 512
Expand Down
44 changes: 38 additions & 6 deletions docs/context-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ librecode can resume very long sessions, rebuild the active branch, and send the
[system] Your input exceeds the context window of this model. Please adjust your input and try again.
```

The current runtime estimates context, but it does not enforce a budget, compact before overflow, or recover automatically when a provider rejects a request. Repeating prompts such as `continue` in an already oversized session sends the same oversized request again.
A related output-budget failure can happen when the provider accepts the request but ends the response before producing a complete assistant turn:

```text
[system] provider response incomplete: max_output_tokens
```

This is not necessarily an input context overflow. It means the provider stopped because the configured or provider-side maximum output token budget was exhausted.

The current runtime estimates context, but it does not enforce a budget, compact before overflow, or recover automatically when a provider rejects a request. It also surfaces incomplete provider responses as terminal errors without guided recovery. Repeating prompts such as `continue` in an already oversized session sends the same oversized request again.

## Current code scan

Expand Down Expand Up @@ -44,9 +52,12 @@ Relevant current behavior from the codebase:
- provider payload includes tool schemas, but context estimation does not count schema overhead.
- `internal/assistant/openai_responses.go`, `openai_chat.go`, `anthropic.go`
- provider payloads add envelopes, tools, reasoning settings, and tool-call loop messages that are not fully represented in preflight estimates.
- `internal/assistant/sse.go`
- `response.incomplete` events become terminal errors through `sseProviderError`.
- `incomplete_details.reason` is surfaced as `provider response incomplete: <reason>`, including `max_output_tokens`.
- `internal/assistant/retry.go`
- context-window/provider token-limit messages are treated as non-retryable transient errors.
- there is no special compact-and-retry path for context overflow.
- there is no special compact-and-retry path for context overflow or incomplete output recovery.

## Design goals

Expand All @@ -56,10 +67,11 @@ Relevant current behavior from the codebase:
4. Keep conservative reserves for output, tool schemas, hidden provider overhead, and estimation error.
5. Automatically compact before overflow.
6. Detect provider context-overflow errors, compact, and retry once.
7. Make manual `/compact` real and useful.
8. Preserve task continuity by keeping a summary plus recent tail turns.
9. Keep the transcript and audit history durable; compact only model context.
10. Expose clear diagnostics through `/context`, events, logs, and extension lifecycle payloads.
7. Classify incomplete responses such as `max_output_tokens` separately from input overflow and present actionable recovery.
8. Make manual `/compact` real and useful.
9. Preserve task continuity by keeping a summary plus recent tail turns.
10. Keep the transcript and audit history durable; compact only model context.
11. Expose clear diagnostics through `/context`, events, logs, and extension lifecycle payloads.

## Non-goals

Expand Down Expand Up @@ -474,6 +486,24 @@ Flow:

This should be independent from transient retry backoff. Context overflow is non-transient, but recoverable via compaction.

## Incomplete response detection

OpenAI Responses streaming can emit `response.incomplete` with `incomplete_details.reason`. librecode currently renders this as:

```text
[system] provider response incomplete: max_output_tokens
```

Treat `max_output_tokens` as an output-budget exhaustion signal, not an input-context overflow signal. Recommended behavior:

1. Preserve any partial assistant text/tool state if the provider supplied usable output.
2. Show a clear local message explaining that generation stopped because the output token budget was exhausted.
3. Suggest concrete next actions: ask the model to continue, reduce requested scope, or increase the configured output token limit when the provider/model allows it.
4. Do not blindly retry the same request with the same output budget; that can reproduce the same incomplete response.
5. If the input context is also near the auto-compaction threshold, offer or run compaction before the next continuation request.

Other incomplete reasons, such as `content_filter`, should keep their provider-specific wording and should not be treated as context-budget failures.

## Provider/tool-loop considerations

Provider payloads differ:
Expand Down Expand Up @@ -580,6 +610,7 @@ context:
| Summary provider fails transiently | normal retry applies |
| Summary provider context-overflows | reduce chunk size/tail and retry |
| Cannot compact enough | local error with next actions |
| Provider response incomplete: `max_output_tokens` | preserve partial output when possible and show output-budget recovery guidance |
| User disables auto-compact | preflight rejects instead of silently sending oversized prompt |
| Tokenizer unavailable | conservative approximate counter |
| Ledger missing/corrupt | ignore ledger and estimate from current context |
Expand All @@ -603,6 +634,7 @@ Unit tests:
- model-facing role inclusion for compaction summaries
- compaction planner tail boundary selection
- overflow error classifier
- incomplete response classifier for `max_output_tokens` versus provider safety/filter reasons
- usage ledger persistence and lookup
- request-hash/calibration logic

Expand Down
10 changes: 5 additions & 5 deletions internal/assistant/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,12 +476,12 @@ func anthropicRole(role database.Role) (string, bool) {
return jsonUserRole, true
case database.RoleAssistant:
return jsonAssistantRole, true
case database.RoleBranchSummary, database.RoleCompactionSummary:
return jsonUserRole, true
case database.RoleCustom, database.RoleBashExecution:
return jsonUserRole, true
case database.RoleToolResult,
database.RoleThinking,
database.RoleCustom,
database.RoleBashExecution,
database.RoleBranchSummary,
database.RoleCompactionSummary:
database.RoleThinking:
return "", false
}

Expand Down
1 change: 1 addition & 0 deletions internal/assistant/anthropic_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func testCompletionRequestAuth(args ...string) *CompletionRequest {
Reasoning: false,
},
ProviderAttempt: 0,
DisableTools: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/assistant/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type CompletionRequest struct {
Usage model.TokenUsage `json:"usage"`
Model model.Model `json:"model"`
ProviderAttempt int `json:"-"`
DisableTools bool `json:"-"`
}

// CompletionResult is a provider response plus model-visible side effects.
Expand Down
138 changes: 138 additions & 0 deletions internal/assistant/context_budget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package assistant

import (
"encoding/json"
"fmt"

"github.com/samber/oops"

"github.com/omarluq/librecode/internal/config"
"github.com/omarluq/librecode/internal/model"
)

const (
defaultContextOutputReserve = 16_384
defaultContextSafetyMargin = 8_192
defaultContextProviderReserve = 2_048
contextReservePercent = 20
)

type contextBudget struct {
InputTokens int
ContextWindow int
UsableInput int
OutputReserve int
ToolSchemaReserve int
ProviderReserve int
SafetyMargin int
}

func newContextBudget(
usage model.TokenUsage,
selectedModel *model.Model,
policy config.ContextConfig,
request *CompletionRequest,
) contextBudget {
contextWindow := usage.ContextWindow
if contextWindow <= 0 && selectedModel != nil {
contextWindow = selectedModel.ContextWindow
}
budget := contextBudget{
InputTokens: usage.ContextTokens,
ContextWindow: contextWindow,
UsableInput: 0,
OutputReserve: contextOutputReserve(selectedModel, contextWindow, policy),
ToolSchemaReserve: estimateToolSchemaTokens(request),
ProviderReserve: nonNegativeOrDefault(policy.ProviderReserveTokens, defaultContextProviderReserve),
SafetyMargin: nonNegativeOrDefault(policy.SafetyMarginTokens, defaultContextSafetyMargin),
}
budget.UsableInput = max(contextWindow-budget.TotalReserve(), 0)

return budget
}

func (budget contextBudget) TotalReserve() int {
return budget.OutputReserve + budget.ToolSchemaReserve + budget.ProviderReserve + budget.SafetyMargin
}

func (budget contextBudget) UsageWithBudget(usage model.TokenUsage) model.TokenUsage {
usage.ContextWindow = budget.ContextWindow
usage.ContextTokens = budget.InputTokens
usage.InputTokens = budget.InputTokens
if usage.Breakdown == nil {
usage.Breakdown = map[string]int{}
}
usage.Breakdown["reserve_output"] = budget.OutputReserve
usage.Breakdown["reserve_tools"] = budget.ToolSchemaReserve
usage.Breakdown["reserve_provider"] = budget.ProviderReserve
usage.Breakdown["reserve_safety"] = budget.SafetyMargin
usage.Breakdown["usable_input"] = budget.UsableInput

return usage
}

func (budget contextBudget) Validate() error {
if budget.ContextWindow <= 0 || budget.InputTokens <= budget.UsableInput {
return nil
}

message := "model context preflight failed: estimated input is %d tokens, " +
"usable input budget is %d tokens after reserving %d of %d context tokens; " +
"start a fresh session or compact the conversation"

return oops.In("assistant").
Code("context_window_exceeded").
With("context_tokens", budget.InputTokens).
With("context_window", budget.ContextWindow).
With("usable_input_tokens", budget.UsableInput).
With("reserved_tokens", budget.TotalReserve()).
Errorf(message, budget.InputTokens, budget.UsableInput, budget.TotalReserve(), budget.ContextWindow)
}

func contextOutputReserve(selectedModel *model.Model, contextWindow int, policy config.ContextConfig) int {
if policy.OutputReserveTokens > 0 {
return policy.OutputReserveTokens
}
if selectedModel != nil && selectedModel.MaxTokens > 0 {
return selectedModel.MaxTokens
}
reserve := defaultContextOutputReserve
if contextWindow > 0 {
reserve = min(reserve, max(1, contextWindow*contextReservePercent/100))
}

return reserve
}

func estimateToolSchemaTokens(request *CompletionRequest) int {
if request == nil {
return 0
}

var tools []map[string]any
switch request.Model.API {
case apiOpenAICompletions:
tools = openAIChatTools(request)
case apiAnthropicMessages:
tools = anthropicTools(request)
default:
tools = responseTools(request)
}
if len(tools) == 0 {
return 0
}
encoded, err := json.Marshal(tools)
if err != nil {
return estimateTokens(fmt.Sprint(tools))
}

return estimateTokens(string(encoded))
}

func nonNegativeOrDefault(value, fallback int) int {
if value >= 0 {
return value
}

return fallback
}
Loading
Loading