agent: reduce repetition and blind writes with dedup caches and range tracking

- Revised system prompt with concrete prohibitions against verbosity and
  premature stopping
- Added GenerationParams (max_tokens, temperature, frequency/presence penalty)
  threading from agent config through backend ChatStream calls
- Added stall detection: emits "stalled" role on max-steps hit or zero tool
  calls with content, surfaces as "stalled" in status bar
- Added per-session file read range tracking: warns on overlapping re-reads,
  blocks file_write unless the target range was previously read
- Added general tool-call dedup: warns on exact (name, args) repeats for
  non-file tools
- Both caches invalidated on /compact and /clear; file read cache invalidated
  per-path on file_write
- Updated all agent configs with documenting defaults for new generation params

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-06 21:29:20 +02:00
parent ea07f6d3a0
commit 1c4c3049ac
7 changed files with 277 additions and 80 deletions

View File

@ -24,14 +24,15 @@ type OutputMsg struct {
} }
type Config struct { type Config struct {
Backend backend.Backend Backend backend.Backend
Model string Model string
Tools []backend.Tool Tools []backend.Tool
Exec ToolExecutor Exec ToolExecutor
Confirm ConfirmFn Confirm ConfirmFn
MaxSteps int MaxSteps int
Output OutputFn Output OutputFn
SystemPrompt string SystemPrompt string
GenerationParams backend.GenerationParams
} }
// ConfirmFn requests user confirmation for an action. Returns true if approved. // ConfirmFn requests user confirmation for an action. Returns true if approved.
@ -43,6 +44,10 @@ func Run(ctx context.Context, cfg Config, state State) error {
maxSteps = 1 maxSteps = 1
} }
var totalToolCalls int
var hadContent bool
hitLimit := false
for step := range maxSteps { for step := range maxSteps {
history := state.History() history := state.History()
if cfg.SystemPrompt != "" { if cfg.SystemPrompt != "" {
@ -53,7 +58,7 @@ func Run(ctx context.Context, cfg Config, state State) error {
var ch <-chan backend.StreamEvent var ch <-chan backend.StreamEvent
for attempt := range maxRateLimitRetries + 1 { for attempt := range maxRateLimitRetries + 1 {
var err error var err error
ch, err = cfg.Backend.ChatStream(ctx, cfg.Model, history, cfg.Tools) ch, err = cfg.Backend.ChatStream(ctx, cfg.Model, history, cfg.Tools, cfg.GenerationParams)
if err == nil { if err == nil {
break break
} }
@ -92,6 +97,10 @@ func Run(ctx context.Context, cfg Config, state State) error {
if !done { if !done {
return fmt.Errorf("step %d: stream ended without done event", step) return fmt.Errorf("step %d: stream ended without done event", step)
} }
if content.Len() > 0 {
hadContent = true
}
totalToolCalls += len(toolCalls)
// Announce and execute tool calls. // Announce and execute tool calls.
msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls} msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls}
@ -149,10 +158,18 @@ func Run(ctx context.Context, cfg Config, state State) error {
break break
} }
if step >= maxSteps-1 { if step >= maxSteps-1 {
hitLimit = true
break break
} }
} }
// Surface stall conditions so the UI can indicate them.
if hitLimit {
emit(cfg, OutputMsg{Role: "stalled", Content: "max steps"})
} else if totalToolCalls == 0 && hadContent {
emit(cfg, OutputMsg{Role: "stalled", Content: "no tools"})
}
return nil return nil
} }

View File

@ -81,7 +81,7 @@ func (s *Session) Compact(ctx context.Context, b backend.Backend, model string)
ch, err := b.ChatStream(ctx, model, []backend.Message{ ch, err := b.ChatStream(ctx, model, []backend.Message{
{Role: "user", Content: prompt}, {Role: "user", Content: prompt},
}, nil) }, nil, backend.GenerationParams{})
if err != nil { if err != nil {
return 0, fmt.Errorf("compact: %w", err) return 0, fmt.Errorf("compact: %w", err)
} }

View File

@ -64,9 +64,18 @@ func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited: %s", e.Message) return fmt.Sprintf("rate limited: %s", e.Message)
} }
// GenerationParams controls sampling behaviour for a single ChatStream call.
// Zero values mean "use the API default".
type GenerationParams struct {
MaxTokens int // 0 = no limit
Temperature *float64 // nil = API default
FrequencyPenalty *float64 // nil = API default
PresencePenalty *float64 // nil = API default
}
// Backend is the interface all LLM providers must implement. // Backend is the interface all LLM providers must implement.
// Streaming is the only supported mode; backends that wrap blocking APIs // Streaming is the only supported mode; backends that wrap blocking APIs
// should implement ChatStream as a single-event stream. // should implement ChatStream as a single-event stream.
type Backend interface { type Backend interface {
ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error)
} }

View File

@ -69,7 +69,7 @@ type ollamaChatResponse struct {
// -- implementation -- // -- implementation --
func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) { func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, _ GenerationParams) (<-chan StreamEvent, error) {
wireMessages := make([]ollamaMessage, len(messages)) wireMessages := make([]ollamaMessage, len(messages))
for i, m := range messages { for i, m := range messages {
wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID} wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID}

View File

@ -65,11 +65,15 @@ type openAIStreamOptions struct {
} }
type openAIChatRequest struct { type openAIChatRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []openAIMessage `json:"messages"` Messages []openAIMessage `json:"messages"`
Tools []openAITool `json:"tools,omitempty"` Tools []openAITool `json:"tools,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
StreamOptions *openAIStreamOptions `json:"stream_options,omitempty"` StreamOptions *openAIStreamOptions `json:"stream_options,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
} }
type openAIUsage struct { type openAIUsage struct {
@ -96,7 +100,7 @@ type openAIStreamResponse struct {
// -- implementation -- // -- implementation --
func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) { func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) {
wireMessages := make([]openAIMessage, len(messages)) wireMessages := make([]openAIMessage, len(messages))
for i, m := range messages { for i, m := range messages {
wm := openAIMessage{ wm := openAIMessage{
@ -133,11 +137,15 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages [
} }
req := openAIChatRequest{ req := openAIChatRequest{
Model: model, Model: model,
Messages: wireMessages, Messages: wireMessages,
Tools: wireTools, Tools: wireTools,
Stream: true, Stream: true,
StreamOptions: &openAIStreamOptions{IncludeUsage: true}, StreamOptions: &openAIStreamOptions{IncludeUsage: true},
MaxTokens: params.MaxTokens,
Temperature: params.Temperature,
FrequencyPenalty: params.FrequencyPenalty,
PresencePenalty: params.PresencePenalty,
} }
data, err := json.Marshal(req) data, err := json.Marshal(req)

View File

@ -6,10 +6,14 @@ import (
) )
type Config struct { type Config struct {
MCPServers map[string]ServerConfig `json:"mcpServers,omitempty"` MCPServers map[string]ServerConfig `json:"mcpServers,omitempty"`
Hooks map[string]string `json:"hooks,omitempty"` Hooks map[string]string `json:"hooks,omitempty"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
TrustedTools []string `json:"trustedTools,omitempty"` TrustedTools []string `json:"trustedTools,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"`
PresencePenalty *float64 `json:"presencePenalty,omitempty"`
} }
type ServerConfig struct { type ServerConfig struct {

265
main.go
View File

@ -8,6 +8,7 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -26,12 +27,11 @@ import (
"ollie/tools" "ollie/tools"
) )
const systemPromptBase = `NEVER describe what you are about to do. NEVER list planned steps. Call tools immediately and directly. const systemPromptBase = `Use the fewest words possible. No preamble, filler, or narration ("Let me...", "I'll now...", "Great!"). No explanations of actions taken. No summaries of completed work. No reasoning unless asked. If the answer is one word, write one word.
Be terse. No preamble, narration, or filler ("Let me...", "I'll now...", "Great!"). Call tools immediately and directly. Never describe what you are about to do — act.
Output only errors, ambiguities requiring clarification, and deliverables. Complete tasks fully before stopping. Do not pause mid-task to narrate progress or request confirmation.
Do not ask clarifying questions unless the task is genuinely ambiguous. Attempt the task; correct based on feedback.
Do not restate tasks, hedge, or self-congratulate. Do not restate tasks, hedge, or self-congratulate.
Always use tools to perform actions; never simulate or guess outputs.
Do not describe what you are about to do. Just do it — emit the tool call immediately.
Do not attempt tasks outside your tools. Do not attempt tasks outside your tools.
Do not use hedging language ("it looks like", "it appears", "it seems", "likely", "probably"). If you are uncertain, use tools to find out. Give definite answers based on evidence. Do not use hedging language ("it looks like", "it appears", "it seems", "likely", "probably"). If you are uncertain, use tools to find out. Give definite answers based on evidence.
Do not re-read or re-fetch any file or resource that already has a result in the conversation history. Use the existing result. Do not re-read or re-fetch any file or resource that already has a result in the conversation history. Use the existing result.
@ -149,6 +149,7 @@ const (
agentRunningTool agentRunningTool
agentRetrying agentRetrying
agentConfirming agentConfirming
agentStalled
) )
// resolveBackendName returns a short human-readable backend label derived // resolveBackendName returns a short human-readable backend label derived
@ -204,10 +205,11 @@ type model struct {
backendName string // e.g. "ollama", "openrouter", "openai" backendName string // e.g. "ollama", "openrouter", "openai"
agentName string // active agent config name, e.g. "default" agentName string // active agent config name, e.g. "default"
agentsDir string // path to ~/.config/ollie/agents/ agentsDir string // path to ~/.config/ollie/agents/
mcpExec *tools.Executor // current agent's MCP executor; closed on agent switch mcpExec *tools.Executor // current agent's MCP executor; closed on agent switch
builtinExec *execpkg.Executor builtinExec *execpkg.Executor
confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run
ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas) ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas)
invalidateCaches func() // clears tool-call dedup caches; set from agentEnv
quitPending bool // whether a second Ctrl+C should quit quitPending bool // whether a second Ctrl+C should quit
lastCtrlC time.Time // timestamp of last Ctrl+C press lastCtrlC time.Time // timestamp of last Ctrl+C press
@ -244,14 +246,16 @@ type timeoutMsg struct{}
// agentEnv holds the runtime state derived from an agent config file. // agentEnv holds the runtime state derived from an agent config file.
type agentEnv struct { type agentEnv struct {
mcpExec *tools.Executor // kept so it can be closed on agent switch mcpExec *tools.Executor // kept so it can be closed on agent switch
tools []backend.Tool tools []backend.Tool
exec agent.ToolExecutor exec agent.ToolExecutor
confirm *agent.ConfirmFn // pointer filled in by startAgent per-run confirm *agent.ConfirmFn // pointer filled in by startAgent per-run
hooks map[string]string hooks map[string]string
systemPrompt string systemPrompt string
ctxOverhead int genParams backend.GenerationParams
messages []string // startup / status messages to display ctxOverhead int
messages []string // startup / status messages to display
invalidateCaches func() // clears per-session tool-call dedup caches on compact/clear
} }
// buildAgentEnv constructs the runtime environment for a given agent config. // buildAgentEnv constructs the runtime environment for a given agent config.
@ -294,6 +298,7 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
hooks := map[string]string{} hooks := map[string]string{}
agentPrompt := "" agentPrompt := ""
trustedTools := map[string]struct{}{} trustedTools := map[string]struct{}{}
var genParams backend.GenerationParams
if cfg != nil { if cfg != nil {
if cfg.Hooks != nil { if cfg.Hooks != nil {
hooks = cfg.Hooks hooks = cfg.Hooks
@ -302,6 +307,12 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
for _, t := range cfg.TrustedTools { for _, t := range cfg.TrustedTools {
trustedTools[t] = struct{}{} trustedTools[t] = struct{}{}
} }
genParams = backend.GenerationParams{
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
FrequencyPenalty: cfg.FrequencyPenalty,
PresencePenalty: cfg.PresencePenalty,
}
} }
sp := systemPrompt(allTools) sp := systemPrompt(allTools)
@ -322,7 +333,19 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
var confirmFn agent.ConfirmFn // filled in by startAgent var confirmFn agent.ConfirmFn // filled in by startAgent
confirmPtr := &confirmFn confirmPtr := &confirmFn
execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) { // fileRanges tracks which line ranges of each file have been read this session.
// Used to warn on overlapping re-reads and to guard file_write against blind overwrites.
// Cleared on /compact and /clear; individual entries deleted when a file is written.
type fileReadState struct {
ranges []lineRange
totalLines int // total lines when last read; used for whole-file write coverage check
}
fileRanges := make(map[string]*fileReadState)
// toolCallSeen tracks all non-file tool calls by exact (name, args) to warn on repeats.
toolCallSeen := make(map[string]bool)
rawExec := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
if server, ok := serverOf[name]; ok { if server, ok := serverOf[name]; ok {
raw, err := mcpExec.Execute(server, name, args) raw, err := mcpExec.Execute(server, name, args)
if err != nil { if err != nil {
@ -335,11 +358,84 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
if _, trusted := trustedTools[name]; !trusted { if _, trusted := trustedTools[name]; !trusted {
cfn = *confirmPtr cfn = *confirmPtr
} }
if name == "file_read" {
var a struct {
Path string `json:"path"`
}
json.Unmarshal(args, &a) //nolint:errcheck
meta, err := dispatchFileRead(cfn, args)
if err != nil {
return "", err
}
content := meta.content
if a.Path != "" {
st := fileRanges[a.Path]
if st != nil && rangesOverlap(st.ranges, meta.start, meta.end) {
content = fmt.Sprintf("[WARNING: Lines %d-%d of this file were already read this session. Do not re-read ranges already in your context.]\n", meta.start, meta.end) + content
}
if st == nil {
st = &fileReadState{}
fileRanges[a.Path] = st
}
st.ranges = append(st.ranges, lineRange{meta.start, meta.end})
st.totalLines = meta.totalLines
}
return content, nil
}
if name == "file_write" {
var a struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
json.Unmarshal(args, &a) //nolint:errcheck
if a.Path != "" {
st := fileRanges[a.Path]
if st == nil {
return "", fmt.Errorf("file_write: %s has not been read this session; read it first to avoid overwriting unknown changes", a.Path)
}
ws, we := a.StartLine, a.EndLine
if ws == 0 && we == 0 {
// Whole-file write: verify full coverage.
totalLines := st.totalLines
if totalLines == 0 {
data, err := os.ReadFile(a.Path)
if err != nil {
return "", fmt.Errorf("file_write: cannot verify read coverage: %w", err)
}
totalLines = len(strings.Split(string(data), "\n"))
}
ws, we = 1, totalLines
}
if !rangesCover(st.ranges, ws, we) {
return "", fmt.Errorf("file_write: lines %d-%d of %s have not been read this session; read them first to avoid overwriting unknown changes", ws, we, a.Path)
}
// Invalidate: file content has changed, any cached ranges are stale.
delete(fileRanges, a.Path)
}
}
return dispatchBuiltinExec(ctx, name, builtinExec, cfn, args) return dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
} }
return "", fmt.Errorf("unknown tool: %s", name) return "", fmt.Errorf("unknown tool: %s", name)
} }
execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
result, err := rawExec(ctx, name, args)
// Warn on repeated identical tool calls (excluding file_read/file_write which
// are handled separately above).
if err == nil && name != "file_read" && name != "file_write" {
key := name + "\x00" + string(args)
if toolCallSeen[key] {
result = "[WARNING: This exact tool call was already made this session. Result may be unchanged. Do not repeat unless something has changed.]\n" + result
}
toolCallSeen[key] = true
}
return result, err
}
return agentEnv{ return agentEnv{
mcpExec: mcpExec, mcpExec: mcpExec,
tools: allTools, tools: allTools,
@ -347,8 +443,13 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
confirm: confirmPtr, confirm: confirmPtr,
hooks: hooks, hooks: hooks,
systemPrompt: sp, systemPrompt: sp,
genParams: genParams,
ctxOverhead: overhead, ctxOverhead: overhead,
messages: messages, messages: messages,
invalidateCaches: func() {
clear(fileRanges)
clear(toolCallSeen)
},
} }
} }
@ -411,12 +512,13 @@ func main() {
startup = append(startup, env.messages...) startup = append(startup, env.messages...)
loopcfg := agent.Config{ loopcfg := agent.Config{
Backend: be, Backend: be,
Model: modelName, Model: modelName,
SystemPrompt: env.systemPrompt, SystemPrompt: env.systemPrompt,
Tools: env.tools, Tools: env.tools,
Exec: env.exec, Exec: env.exec,
MaxSteps: 20, MaxSteps: 20,
GenerationParams: env.genParams,
} }
ta := textarea.New() ta := textarea.New()
@ -439,21 +541,22 @@ func main() {
} }
p := tea.NewProgram(model{ p := tea.NewProgram(model{
textarea: ta, textarea: ta,
viewport: vp, viewport: vp,
loopcfg: loopcfg, loopcfg: loopcfg,
hooks: env.hooks, hooks: env.hooks,
display: startup, display: startup,
modelName: modelName, modelName: modelName,
backendName: backendName, backendName: backendName,
agentName: agentName, agentName: agentName,
agentsDir: agentsDir, agentsDir: agentsDir,
mcpExec: env.mcpExec, mcpExec: env.mcpExec,
builtinExec: builtinExec, builtinExec: builtinExec,
confirmPtr: env.confirm, confirmPtr: env.confirm,
ctxOverhead: env.ctxOverhead, ctxOverhead: env.ctxOverhead,
quitPending: false, invalidateCaches: env.invalidateCaches,
state: agentIdle, quitPending: false,
state: agentIdle,
}) })
if hook := env.hooks["agentSpawn"]; hook != "" { if hook := env.hooks["agentSpawn"]; hook != "" {
@ -507,6 +610,8 @@ func (m model) renderStatusBar() string {
stateStr = fmt.Sprintf("retry %ds", m.retrySecsLeft) stateStr = fmt.Sprintf("retry %ds", m.retrySecsLeft)
case agentConfirming: case agentConfirming:
stateStr = "confirm [y/n]" stateStr = "confirm [y/n]"
case agentStalled:
stateStr = "stalled"
} }
// Token usage segment. // Token usage segment.
@ -600,6 +705,9 @@ func (m *model) apply(am agentMsg) {
m.confirmCh = am.confirmCh m.confirmCh = am.confirmCh
m.appendDisplay("Confirm: " + am.content + " [y/n]") m.appendDisplay("Confirm: " + am.content + " [y/n]")
case "stalled":
m.state = agentStalled
case "usage": case "usage":
// Update status bar only; no display line appended. // Update status bar only; no display line appended.
m.lastUsage = am.usage m.lastUsage = am.usage
@ -668,7 +776,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.apply(msg) m.apply(msg)
m.refreshView() m.refreshView()
if msg.done { if msg.done {
m.state = agentIdle if m.state != agentStalled {
m.state = agentIdle
}
m.currentTool = "" m.currentTool = ""
m.agentCh = nil m.agentCh = nil
m.cancel = nil m.cancel = nil
@ -869,8 +979,10 @@ func (m *model) handleCommand(input string) bool {
m.loopcfg.SystemPrompt = env.systemPrompt m.loopcfg.SystemPrompt = env.systemPrompt
m.loopcfg.Tools = env.tools m.loopcfg.Tools = env.tools
m.loopcfg.Exec = env.exec m.loopcfg.Exec = env.exec
m.loopcfg.GenerationParams = env.genParams
m.ctxOverhead = env.ctxOverhead m.ctxOverhead = env.ctxOverhead
m.confirmPtr = env.confirm m.confirmPtr = env.confirm
m.invalidateCaches = env.invalidateCaches
m.agentName = name m.agentName = name
m.session = nil // new agent, new session m.session = nil // new agent, new session
for _, msg := range env.messages { for _, msg := range env.messages {
@ -891,6 +1003,9 @@ func (m *model) handleCommand(input string) bool {
m.display = append(m.display, "nothing to compact") m.display = append(m.display, "nothing to compact")
} else { } else {
m.display = append(m.display, fmt.Sprintf("compacted %d messages", n)) m.display = append(m.display, fmt.Sprintf("compacted %d messages", n))
if m.invalidateCaches != nil {
m.invalidateCaches()
}
} }
return true return true
@ -922,6 +1037,9 @@ func (m *model) handleCommand(input string) bool {
m.session = nil m.session = nil
m.display = nil m.display = nil
m.buf = "" m.buf = ""
if m.invalidateCaches != nil {
m.invalidateCaches()
}
return true return true
case "/help": case "/help":
@ -1098,7 +1216,8 @@ func extractMCPText(raw json.RawMessage) string {
func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) { func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
switch name { switch name {
case "file_read": case "file_read":
return dispatchFileRead(confirm, args) r, err := dispatchFileRead(confirm, args)
return r.content, err
case "file_write": case "file_write":
return dispatchFileWrite(confirm, args) return dispatchFileWrite(confirm, args)
case "execute_pipe": case "execute_pipe":
@ -1226,28 +1345,68 @@ func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent
const fileReadMaxLines = 500 const fileReadMaxLines = 500
func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, error) { // lineRange is an inclusive [start, end] line range (1-based).
type lineRange struct{ start, end int }
// fileReadResult carries the output of dispatchFileRead plus the range metadata
// needed for per-session coverage tracking.
type fileReadResult struct {
content string
start int // actual first line returned (1-based)
end int // actual last line returned (1-based)
totalLines int // total lines in the file before any truncation
}
// rangesOverlap reports whether [ws, we] overlaps any interval in ranges.
func rangesOverlap(ranges []lineRange, ws, we int) bool {
for _, r := range ranges {
if r.start <= we && r.end >= ws {
return true
}
}
return false
}
// rangesCover reports whether the union of ranges fully contains [ws, we].
func rangesCover(ranges []lineRange, ws, we int) bool {
sorted := make([]lineRange, len(ranges))
copy(sorted, ranges)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].start < sorted[j].start })
cur := 0
for _, r := range sorted {
if r.start > cur+1 {
break
}
if r.end > cur {
cur = r.end
}
}
return cur >= we
}
func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (fileReadResult, error) {
var a struct { var a struct {
Path string `json:"path"` Path string `json:"path"`
StartLine int `json:"start_line"` StartLine int `json:"start_line"`
EndLine int `json:"end_line"` EndLine int `json:"end_line"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("file_read: bad args: %w", err) return fileReadResult{}, fmt.Errorf("file_read: bad args: %w", err)
} }
if a.Path == "" { if a.Path == "" {
return "", fmt.Errorf("file_read: 'path' is required") return fileReadResult{}, fmt.Errorf("file_read: 'path' is required")
} }
if confirm != nil && !confirm(fmt.Sprintf("read %s", a.Path)) { if confirm != nil && !confirm(fmt.Sprintf("read %s", a.Path)) {
return "", fmt.Errorf("file_read: denied by user") return fileReadResult{}, fmt.Errorf("file_read: denied by user")
} }
data, err := os.ReadFile(a.Path) data, err := os.ReadFile(a.Path)
if err != nil { if err != nil {
return "", fmt.Errorf("file_read: %w", err) return fileReadResult{}, fmt.Errorf("file_read: %w", err)
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
totalLines := len(lines)
start := 1 start := 1
end := len(lines) end := totalLines
if a.StartLine > 0 { if a.StartLine > 0 {
start = a.StartLine start = a.StartLine
} }
@ -1257,11 +1416,11 @@ func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, er
if start < 1 { if start < 1 {
start = 1 start = 1
} }
if end > len(lines) { if end > totalLines {
end = len(lines) end = totalLines
} }
if start > end { if start > end {
return "", fmt.Errorf("file_read: start_line %d > end_line %d", start, end) return fileReadResult{}, fmt.Errorf("file_read: start_line %d > end_line %d", start, end)
} }
truncated := false truncated := false
if end-start+1 > fileReadMaxLines { if end-start+1 > fileReadMaxLines {
@ -1272,11 +1431,11 @@ func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, er
for i, line := range lines[start-1 : end] { for i, line := range lines[start-1 : end] {
fmt.Fprintf(&out, "%d\t%s\n", start+i, line) fmt.Fprintf(&out, "%d\t%s\n", start+i, line)
} }
result := strings.TrimRight(out.String(), "\n") content := strings.TrimRight(out.String(), "\n")
if truncated { if truncated {
result += fmt.Sprintf("\n[truncated: showing lines %d-%d of %d; use start_line/end_line or grep -n to narrow range]", start, end, len(lines)) content += fmt.Sprintf("\n[truncated: showing lines %d-%d of %d; use start_line/end_line or grep -n to narrow range]", start, end, totalLines)
} }
return result, nil return fileReadResult{content: content, start: start, end: end, totalLines: totalLines}, nil
} }
func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, error) { func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, error) {