Skip to content

Commit c5545a0

Browse files
torrmalclaude
andcommitted
Choose a turn's files before asking the model for them
Every coding turn opened the same way: one call that read the map and came back asking for files, then a second that did the work. The first existed only to pick, and it cost three seconds of the twenty-four a median turn takes. A decision model picks in about one. Measured before building it, over fourteen real turns from the session log against the files those turns actually changed: 968ms at the median, and the whole file list costs the same as a single question, because the call is priced per request rather than per question. At a 0.35 threshold it named every file that mattered in thirteen of the fourteen, carrying about 1.6 files that did not. The threshold is swept, not chosen: 0.5 misses a file in three of fourteen, and below 0.35 the last one costs four extra files a turn, which is a worse trade than the round trip it buys. Off by default and switched on with /preselect, because whether it is worth keeping is a number rather than an opinion, and the number differs per project. The profile line carries "files" for what it cost, and a turn that no longer opens with a "thought" is what it saved. It behaves like an optimization throughout. A 504 turned up once in twenty-five real calls, so a timeout, a bad status, a reply that will not parse, or an endpoint that has never heard of decisions all mean the same thing: choose nothing, and the turn proceeds exactly as it did before with the model asking for what it wants. Everything above the line is treated as a shrug rather than a selection — reading the whole repository costs more than the call it saved — and a map past eighty files is left alone, since the model narrows that by name far more cheaply than a question each would. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c291cfe commit c5545a0

7 files changed

Lines changed: 404 additions & 9 deletions

File tree

‎cmd/yolocoder/main.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ func runCommand(input string, fromEnvironment bool, provider *config.LLM) (handl
194194
toggleRecall(fromEnvironment, provider)
195195
fmt.Println()
196196
return true, false
197+
case "/preselect":
198+
togglePreselect(fromEnvironment, provider)
199+
fmt.Println()
200+
return true, false
197201
case "/setup":
198202
if fromEnvironment {
199203
fmt.Println("[*_*] /setup can't change an OPENAI_* environment provider; restart without --llm-from-env-vars to use a saved one.")
@@ -248,6 +252,28 @@ func toggleRecall(fromEnvironment bool, provider *config.LLM) {
248252
state, map[bool]string{true: "can now", false: "can no longer"}[provider.Recall], 3)
249253
}
250254

255+
// togglePreselect turns file preselection on or off from here on, the
256+
// same way /recall does, and for the same reason: it is worth keeping
257+
// only while it costs less than the call it removes, so it has to be
258+
// possible to run a few turns each way.
259+
func togglePreselect(fromEnvironment bool, provider *config.LLM) {
260+
provider.Preselect = !provider.Preselect
261+
state := "off"
262+
if provider.Preselect {
263+
state = "on"
264+
}
265+
if fromEnvironment {
266+
fmt.Printf("[*_*] File preselection %s for this session. An OPENAI_* environment provider can't be saved.\n", state)
267+
return
268+
}
269+
if err := config.Save(*provider); err != nil {
270+
fmt.Printf("[*_*] File preselection %s for this session, but it could not be saved: %v\n", state, err)
271+
return
272+
}
273+
fmt.Printf("[*_*] File preselection %s. A turn %s the files it needs before asking the model for them.\n",
274+
state, map[bool]string{true: "now picks", false: "no longer picks"}[provider.Preselect])
275+
}
276+
251277
// openHistory starts or continues this folder's session log.
252278
func openHistory() (*session.Log, error) {
253279
folder, err := os.Getwd()

‎internal/agent/agent.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ type Runner struct {
222222
// few turns of it ride along in the opening message; the rest is held
223223
// here for the recall tool to serve, when that tool is offered at all.
224224
earlier []Recollection
225+
// preselect reports whether the files a task needs are chosen before
226+
// the model is asked, rather than by it.
227+
preselect bool
225228
// recall reports whether the recall tool is offered this run. Off by
226229
// default (see config.LLM.Recall): the inline turns already answer
227230
// almost every follow-up, and an unused tool is still a definition on
@@ -241,6 +244,10 @@ func NewRunner(client *Client, repository *repo.Repository) *Runner {
241244
// the turns carried inline.
242245
func (runner *Runner) UseRecall(on bool) { runner.recall = on }
243246

247+
// UsePreselect turns on choosing a turn's files before the model is
248+
// asked for them.
249+
func (runner *Runner) UsePreselect(on bool) { runner.preselect = on }
250+
244251
// inlineTurns is how many of the most recent turns ride along in the
245252
// opening message. Measured on a real folder, three of them come to
246253
// around 1.5 KB — far less than a round trip spent fetching them would
@@ -310,6 +317,21 @@ func (runner *Runner) Run(ctx context.Context, task string, images []string, his
310317

311318
progress.Status("Working out the change...")
312319
session := runner.newChangeSession(task, repoMap, notes, images)
320+
321+
// The files first, if that is switched on. It is an optimization and
322+
// behaves like one: anything at all going wrong leaves the turn
323+
// exactly as it was, with the model asking for what it wants.
324+
if runner.preselect {
325+
progress.Status("Choosing the files...")
326+
started := time.Now()
327+
chosen := runner.chooseFiles(ctx, task, mapped)
328+
spent := time.Since(started)
329+
runner.profile.record(StepFiles, spent)
330+
if len(chosen) > 0 {
331+
session.preread(chosen, progress)
332+
}
333+
progress.Log(" chose files · " + formatDuration(spent))
334+
}
313335
outcome, err := session.work(ctx, progress)
314336
// The whole-file fallback is for a model that ran out of room, not
315337
// one that made up its mind. A turn that finished on its own saying

‎internal/agent/decide.go‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"sort"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
"github.com/mindsdb/yolocoder/internal/debug"
16+
)
17+
18+
// Choosing the files a task needs, before the model is asked anything.
19+
//
20+
// Every coding turn otherwise opens the same way: one call that reads the
21+
// map and comes back asking for files, then a second that does the work.
22+
// The first exists only to pick, and a decision model picks in about a
23+
// second where that call takes three.
24+
//
25+
// Measured over fourteen real turns from a session log, against the files
26+
// those turns actually changed: 968ms at the median, and the whole file
27+
// list costs the same as one question — the call is priced per request,
28+
// not per question. At the threshold below it named every file that
29+
// mattered in thirteen of fourteen, carrying about 1.6 files that did not.
30+
//
31+
// Being wrong is survivable and being slow is not, which is what the
32+
// timeout and the silent fall-through below are for: a miss costs the
33+
// round trip this was saving, and nothing else, because the model still
34+
// has read_files.
35+
36+
const (
37+
// decideThreshold is where a probability becomes a file worth
38+
// reading. Swept over the same fourteen turns: 0.5 misses a file in
39+
// three of them, 0.35 misses one, and going lower buys the last one
40+
// at four extra files a turn, which is a worse trade than the round
41+
// trip it saves.
42+
decideThreshold = 0.35
43+
44+
// decideTimeout is short because this is an optimization. Past this
45+
// the turn is better off doing what it always did.
46+
decideTimeout = 6 * time.Second
47+
48+
// maxDecideFiles bounds what is worth asking about. Latency does not
49+
// grow with the question count but the prompt does — nineteen files
50+
// cost about 900 input tokens — and past this the map is better left
51+
// to the model, which can narrow it by name far more cheaply.
52+
maxDecideFiles = 80
53+
54+
// maxDecidePicks stops a shrug from turning into the whole repository
55+
// being read. If this many come back above the line, the answer was
56+
// not a selection.
57+
maxDecidePicks = 8
58+
)
59+
60+
type decisionRequest struct {
61+
Model string `json:"model"`
62+
State any `json:"state"`
63+
Questions map[string]question `json:"questions"`
64+
}
65+
66+
type question struct {
67+
Type string `json:"type"`
68+
Instructions string `json:"instructions"`
69+
}
70+
71+
type decisionReply struct {
72+
Answers map[string]struct {
73+
Noul float64 `json:"noul"`
74+
} `json:"answers"`
75+
}
76+
77+
// decisionsEndpoint is where the decision model answers, beside the
78+
// completions route the rest of this package speaks to.
79+
func decisionsEndpoint(baseURL string) string {
80+
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
81+
if strings.HasSuffix(baseURL, "/v1") {
82+
return baseURL + "/decisions"
83+
}
84+
return baseURL + "/v1/decisions"
85+
}
86+
87+
// chooseFiles are the files a task probably needs, or nil.
88+
//
89+
// nil is the ordinary answer to anything going wrong — a timeout, a 504,
90+
// an endpoint that has never heard of decisions — and the turn carries on
91+
// exactly as it did before. Nothing here is allowed to fail a turn.
92+
func (runner *Runner) chooseFiles(ctx context.Context, task string, paths []string) []string {
93+
if len(paths) == 0 || len(paths) > maxDecideFiles {
94+
return nil
95+
}
96+
questions := make(map[string]question, len(paths))
97+
for index, path := range paths {
98+
questions["f"+strconv.Itoa(index)] = question{
99+
Type: "noul",
100+
Instructions: "Is the file " + path + " one that must be read or edited to do this task?",
101+
}
102+
}
103+
payload, err := json.Marshal(decisionRequest{
104+
Model: "jev",
105+
State: map[string]any{"task": task, "files": paths},
106+
Questions: questions,
107+
})
108+
if err != nil {
109+
return nil
110+
}
111+
112+
callCtx, cancel := context.WithTimeout(ctx, decideTimeout)
113+
defer cancel()
114+
request, err := http.NewRequestWithContext(callCtx, http.MethodPost,
115+
decisionsEndpoint(runner.client.baseURL), bytes.NewReader(payload))
116+
if err != nil {
117+
return nil
118+
}
119+
request.Header.Set("Authorization", "Bearer "+runner.client.apiKey)
120+
request.Header.Set("Content-Type", "application/json")
121+
122+
response, err := runner.client.http.Do(request)
123+
if err != nil {
124+
debug.Logf("DECIDE", "%v", err)
125+
return nil
126+
}
127+
defer response.Body.Close()
128+
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
129+
if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
130+
debug.Logf("DECIDE", "%s: %s", response.Status, snippet(string(body)))
131+
return nil
132+
}
133+
var reply decisionReply
134+
if err := json.Unmarshal(body, &reply); err != nil {
135+
debug.Logf("DECIDE", "decode: %v", err)
136+
return nil
137+
}
138+
139+
type scored struct {
140+
path string
141+
odds float64
142+
}
143+
var above []scored
144+
for name, answer := range reply.Answers {
145+
index, err := strconv.Atoi(strings.TrimPrefix(name, "f"))
146+
if err != nil || index < 0 || index >= len(paths) || answer.Noul < decideThreshold {
147+
continue
148+
}
149+
above = append(above, scored{paths[index], answer.Noul})
150+
}
151+
if len(above) == 0 || len(above) > maxDecidePicks {
152+
return nil
153+
}
154+
// Most confident first, so a read that hits the byte ceiling drops
155+
// the file it was least sure about.
156+
sort.Slice(above, func(i, j int) bool { return above[i].odds > above[j].odds })
157+
chosen := make([]string, 0, len(above))
158+
for _, one := range above {
159+
chosen = append(chosen, one.path)
160+
}
161+
return chosen
162+
}
163+
164+
// preread puts the chosen files into the conversation as though the model
165+
// had asked for them, which is what makes the round trip disappear: it
166+
// opens already holding what it would otherwise have spent a call
167+
// requesting. Reported honestly on the trail — these are files nobody
168+
// asked for, and a turn that goes wrong afterwards should not leave
169+
// anyone wondering where they came from.
170+
func (session *changeSession) preread(paths []string, progress Progress) {
171+
text, err := session.runner.readFiles(paths)
172+
if err != nil {
173+
return
174+
}
175+
session.readPaths = append(session.readPaths, paths...)
176+
session.transcript = append(session.transcript, inputMessage{
177+
Role: "user",
178+
Content: fmt.Sprintf("These look like the files this needs, so here they are already. "+
179+
"Read anything else you want with read_files.\n\n%s", text),
180+
})
181+
progress.Log(" picked " + strings.Join(paths, ", "))
182+
}

0 commit comments

Comments
 (0)