diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 6fccbaf53..ab9c36d05 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -333,6 +333,8 @@ func skillFilesModifiedSince(skillsDir string, t time.Time) bool { return changed } +const maxBootstrapFileChars = 4096 + func (cb *ContextBuilder) LoadBootstrapFiles() string { bootstrapFiles := []string{ "AGENTS.md", @@ -345,7 +347,11 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { for _, filename := range bootstrapFiles { filePath := filepath.Join(cb.workspace, filename) if data, err := os.ReadFile(filePath); err == nil { - fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) + content := string(data) + if len(content) > maxBootstrapFileChars { + content = content[:maxBootstrapFileChars] + "\n... (truncated)" + } + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, content) } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8f315ece9..973a1a45a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -969,8 +969,19 @@ func (al *AgentLoop) runLLMIteration( } messages = append(messages, toolResultMsg) - // Save tool result message to session - agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) + // Save a truncated version of tool results to session history. + // Full results are already in `messages` for the current iteration; + // persisting truncated versions prevents context bloat on reload. + truncatedContent := contentForLLM + const maxToolResultHistory = 500 + if len(truncatedContent) > maxToolResultHistory { + truncatedContent = truncatedContent[:maxToolResultHistory] + fmt.Sprintf("... (truncated, %d chars total)", len(contentForLLM)) + } + agent.Sessions.AddFullMessage(opts.SessionKey, providers.Message{ + Role: "tool", + Content: truncatedContent, + ToolCallID: tc.ID, + }) } } @@ -1014,7 +1025,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * 75 / 100 - if len(newHistory) > 20 || tokenEstimate > threshold { + if len(newHistory) > 12 || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 62eb703c6..2ba1f041e 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -119,6 +120,26 @@ func (t *ReadFileTool) Parameters() map[string]any { const maxReadFileChars = 10000 +func isBinaryContent(data []byte) bool { + if len(data) == 0 { + return false + } + sample := data + if len(sample) > 512 { + sample = sample[:512] + } + if !utf8.Valid(sample) { + return true + } + nullCount := 0 + for _, b := range sample { + if b == 0 { + nullCount++ + } + } + return nullCount > 0 +} + func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { @@ -130,6 +151,10 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(err.Error()) } + if isBinaryContent(content) { + return ErrorResult(fmt.Sprintf("binary file detected (%d bytes). Use exec to inspect binary files (e.g. file, xxd, strings)", len(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)) @@ -238,15 +263,25 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes return formatDirEntries(entries) } +const maxDirEntries = 200 + func formatDirEntries(entries []os.DirEntry) *ToolResult { var result strings.Builder - for _, entry := range entries { - if entry.IsDir() { - result.WriteString("DIR: " + entry.Name() + "\n") + total := len(entries) + limit := total + if limit > maxDirEntries { + limit = maxDirEntries + } + for i := 0; i < limit; i++ { + if entries[i].IsDir() { + result.WriteString("DIR: " + entries[i].Name() + "\n") } else { - result.WriteString("FILE: " + entry.Name() + "\n") + result.WriteString("FILE: " + entries[i].Name() + "\n") } } + if total > maxDirEntries { + fmt.Fprintf(&result, "\n... (%d more entries not shown, total: %d)\n", total-maxDirEntries, total) + } return NewToolResult(result.String()) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 69f1a49a2..0f490cff2 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -359,13 +359,18 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe userContent = userContent[:maxUserLen] + "..." } - // ForLLM: Full execution details + // ForLLM: Execution details (truncated to control token usage) labelStr := label if labelStr == "" { labelStr = "(unnamed)" } + resultContent := loopResult.Content + const maxSubagentLLM = 3000 + if len(resultContent) > maxSubagentLLM { + resultContent = resultContent[:maxSubagentLLM] + fmt.Sprintf("... (truncated, %d chars total)", len(loopResult.Content)) + } llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s", - labelStr, loopResult.Iterations, loopResult.Content) + labelStr, loopResult.Iterations, resultContent) return &ToolResult{ ForLLM: llmContent, diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 30ccfe603..8470f29dc 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -661,21 +661,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe text = text[:maxChars] } - result := map[string]any{ - "url": urlStr, - "status": resp.StatusCode, - "extractor": extractor, - "truncated": truncated, - "length": len(text), - "text": text, + var llmContent string + if truncated { + llmContent = fmt.Sprintf("[%s %d truncated=%v]\n%s", urlStr, resp.StatusCode, truncated, text) + } else { + llmContent = fmt.Sprintf("[%s %d]\n%s", urlStr, resp.StatusCode, text) } - resultJSON, _ := json.MarshalIndent(result, "", " ") - return &ToolResult{ - ForLLM: string(resultJSON), + ForLLM: llmContent, ForUser: fmt.Sprintf( - "Fetched %d bytes from %s (extractor: %s, truncated: %v)", + "Fetched %d chars from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor,