perf: reduce API costs with 6 optimization fixes

1. read_file: truncate output to 10K chars (was unlimited)
2. max_tool_iterations: reduce default from 50 to 15
3. web_fetch: reduce default from 50K to 20K chars
4. Anthropic: add cache_control on last tool definition for prompt caching
5. Token usage logging: track and log cumulative input/output tokens per session
6. max_tokens: reduce default from 32K to 8K, resolve real context window per model

Made-with: Cursor
This commit is contained in:
A Magno 2026-03-02 12:36:59 +01:00
parent 6b66da52f0
commit a99cf50f27
6 changed files with 63 additions and 8 deletions

View file

@ -148,6 +148,8 @@ func NewAgentInstance(
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
contextWindow := resolveContextWindow(model, maxTokens)
return &AgentInstance{ return &AgentInstance{
ID: agentID, ID: agentID,
Name: agentName, Name: agentName,
@ -157,7 +159,7 @@ func NewAgentInstance(
MaxIterations: maxIter, MaxIterations: maxIter,
MaxTokens: maxTokens, MaxTokens: maxTokens,
Temperature: temperature, Temperature: temperature,
ContextWindow: maxTokens, ContextWindow: contextWindow,
Provider: provider, Provider: provider,
Sessions: sessionsManager, Sessions: sessionsManager,
ContextBuilder: contextBuilder, ContextBuilder: contextBuilder,
@ -223,3 +225,29 @@ func expandHome(path string) string {
} }
return path return path
} }
// resolveContextWindow returns the actual context window size for a model.
// This is used for summarization thresholds and should reflect the model's
// real capacity rather than the output max_tokens setting.
func resolveContextWindow(model string, maxTokens int) int {
m := strings.ToLower(model)
switch {
case strings.Contains(m, "claude"):
return 200000
case strings.Contains(m, "gpt-5"), strings.Contains(m, "gpt-4"):
return 128000
case strings.Contains(m, "gemini"):
return 1000000
case strings.Contains(m, "deepseek"):
return 64000
case strings.Contains(m, "llama"):
return 128000
case strings.Contains(m, "qwen"):
return 32000
default:
if maxTokens > 32000 {
return maxTokens
}
return 32000
}
}

View file

@ -123,7 +123,7 @@ func registerSharedTools(
} else if searchTool != nil { } else if searchTool != nil {
agent.Tools.Register(searchTool) agent.Tools.Register(searchTool)
} }
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) fetchTool, err := tools.NewWebFetchToolWithProxy(20000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
if err != nil { if err != nil {
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
} else { } else {
@ -661,6 +661,7 @@ func (al *AgentLoop) runLLMIteration(
) (string, int, error) { ) (string, int, error) {
iteration := 0 iteration := 0
var finalContent string var finalContent string
var totalInputTokens, totalOutputTokens int
for iteration < agent.MaxIterations { for iteration < agent.MaxIterations {
iteration++ iteration++
@ -805,6 +806,11 @@ func (al *AgentLoop) runLLMIteration(
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
if response.Usage != nil {
totalInputTokens += response.Usage.PromptTokens
totalOutputTokens += response.Usage.CompletionTokens
}
logger.DebugCF("agent", "LLM response", logger.DebugCF("agent", "LLM response",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -968,6 +974,17 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
if totalInputTokens > 0 || totalOutputTokens > 0 {
logger.InfoCF("agent", "Session token usage",
map[string]any{
"agent_id": agent.ID,
"iterations": iteration,
"input_tokens": totalInputTokens,
"output_tokens": totalOutputTokens,
"total_tokens": totalInputTokens + totalOutputTokens,
})
}
return finalContent, iteration, nil return finalContent, iteration, nil
} }

View file

@ -30,9 +30,9 @@ func DefaultConfig() *Config {
RestrictToWorkspace: true, RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "", Model: "",
MaxTokens: 32768, MaxTokens: 8192,
Temperature: nil, // nil means use provider default Temperature: nil, // nil means use provider default
MaxToolIterations: 50, MaxToolIterations: 15,
}, },
}, },
Bindings: []AgentBinding{}, Bindings: []AgentBinding{},

View file

@ -237,7 +237,7 @@ func buildParams(
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
result := make([]anthropic.ToolUnionParam, 0, len(tools)) result := make([]anthropic.ToolUnionParam, 0, len(tools))
for _, t := range tools { for i, t := range tools {
tool := anthropic.ToolParam{ tool := anthropic.ToolParam{
Name: t.Function.Name, Name: t.Function.Name,
InputSchema: anthropic.ToolInputSchemaParam{ InputSchema: anthropic.ToolInputSchemaParam{
@ -256,6 +256,9 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
} }
tool.InputSchema.Required = required tool.InputSchema.Required = required
} }
if i == len(tools)-1 {
tool.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
} }
return result return result

View file

@ -117,6 +117,8 @@ func (t *ReadFileTool) Parameters() map[string]any {
} }
} }
const maxReadFileChars = 10000
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
@ -127,7 +129,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if err != nil { if err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return NewToolResult(string(content))
text := string(content)
if len(text) > maxReadFileChars {
text = text[:maxReadFileChars] + fmt.Sprintf("\n\n... (truncated, showing %d of %d chars. Use exec with head/tail/sed for specific sections)", maxReadFileChars, len(content))
}
return NewToolResult(text)
} }
type WriteFileTool struct { type WriteFileTool struct {

View file

@ -22,7 +22,7 @@ const (
perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower) perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower)
fetchTimeout = 60 * time.Second // WebFetchTool fetchTimeout = 60 * time.Second // WebFetchTool
defaultMaxChars = 50000 defaultMaxChars = 20000
maxRedirects = 5 maxRedirects = 5
) )