Repository navigation
Expand file tree
/
Copy pathtools.go
More file actions
2044 lines (1942 loc) · 89.7 KB
/
Copy pathtools.go
File metadata and controls
2044 lines (1942 loc) · 89.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package exec
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/Agent-Field/agentfield/sdk/go/ai"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/ctxbudget"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/executor"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/rtk"
commandshell "github.com/Agent-Field/codeaf/internal/shell"
"github.com/Agent-Field/codeaf/internal/store"
)
// The base tool set is five tools, and the count is the design. Pull-only
// recall and configured media capabilities are appended at the leaf boundary.
//
// Every definition is re-sent on every turn, and every result stays in context
// for every turn after it arrives, so the question is not "what would be
// convenient" but "what earns its place in a prompt paid for repeatedly".
//
// sh one definition buys read, list, search, find, move, curl, and
// everything nobody has thought of yet. The shell already composes, so
// batching needs no schema help.
// job keeps long-lived shell work from blocking the linear loop while
// preserving shell composition for logs and readiness monitors.
// write because content must not pass through a shell. Emitting a document
// through a heredoc means quoting prose, which corrupts it and burns
// tokens on escaping.
// edit because changing a paragraph should cost a paragraph, not a whole
// file re-typed.
// web the only capability a shell genuinely lacks. urls is plural because
// research is search-once-then-read-several, and batching that turns
// five round-trips into one.
const maxCommandSeconds = 120
// The four bounds on what one tool result may cost, stated as the fractions of
// the leaf's observation window they have always secretly been.
//
// Each number below was tuned against a 32KB window — the one an unrecognised
// model still gets, defaultObservationBudget — and each was then written down as
// an absolute, which is the mistake the fill law exists to undo: a model holding
// a million tokens was handed the same twelve kilobytes as one holding thirty-two
// thousand. So the literals stay exactly as they are and change job. They are the
// numerator of a share of the window (see toolBudgetsFor), and they are the named
// fallback for the case where the window is unknown, which is what keeps offline
// behaviour byte-for-byte what it was.
//
// The ratios between them are the part that must not drift, and expressing all
// four against one reference window is what holds them: preview stays below
// spill, spill stays below the result cap, and recall stays the tightest of the
// three that bound a whole result.
const (
// maxToolResultBytes is the largest a single result may be before its middle
// is elided — see clamp, which keeps the head and the verdict at the end.
maxToolResultBytes = 12 << 10
// spillBytes is where a result stops being worth carrying. Anything larger
// is written to a file and represented in context by a preview and a path.
//
// This is the single highest-leverage bound in the system. A turn resends
// every earlier observation, so a k-turn loop pays for its results k times
// over — one 25-turn leaf was 54% of a whole run's input tokens. Spilling
// caps what any observation can cost, and the model loses nothing it cannot
// get back: reading part of a file is one `sh` call away.
//
// The threshold sits where a typical single document — a source file, a
// fetched page's article body — still fits whole. Set at 4KB it truncated
// most of the very files an agent was asked to change, and the agent spent
// its run re-fetching slices of things it had already read. Aging is the
// decay pass's job; spill only has to stop the genuinely huge result.
spillBytes = 10 << 10
previewBytes = 4 << 10
// maxRecallResultBytes bounds persistent memory more tightly than ordinary
// observations: recall is a map used to choose what to read, not a second
// copy of the territory.
maxRecallResultBytes = 8 << 10
// toolBudgetReference is the window the four numbers above were measured
// against, and therefore the denominator that turns each of them into a
// fraction. It is defaultObservationBudget itself rather than a copy of its
// value: the reference window and the unknown-model window are the same
// fact, and a second spelling of it would drift.
toolBudgetReference = defaultObservationBudget
)
// maxResultLines is the other half of every result bound, and the half no byte
// count can express.
//
// A byte cap cuts where the byte falls: mid-line, mid-path, mid-JSON. What the
// model gets back is a half-line it cannot use and cannot address, so it
// re-runs the command to see the whole of it — the exact duplication the caps
// exist to prevent. Lines are the unit the shell speaks in and the unit sed,
// head and tail address, so a cut on a line boundary is a cut the continuation
// command in the notice can name exactly: `sed -n '2001,4000p' <file>` is only
// a true statement about where the reader got to if the reader got to the end
// of a line.
//
// The two caps bind together, whichever is reached first, because output comes
// in two shapes and each defeats the other cap alone. A build log is thin and
// long — two thousand lines of it is well inside any byte budget, and without a
// line cap the model is handed a wall it will not read. A minified bundle or a
// one-line JSON dump is the opposite — a single line of megabytes, inside any
// line cap, which the byte cap is the only thing standing in front of.
//
// Two thousand lines is where a
// listing stops being read and starts being searched: past it the right move is
// never "read more", it is grep, and the notice says where to grep.
const maxResultLines = 2000
// resultShape says which END of an over-long result is the end worth keeping.
// It travels on the Result because only the tool that produced it knows, and
// getting it wrong is expensive in a way truncation normally is not: keeping
// the head of a failed build discards the error, and keeping the tail of a
// document discards the part that was asked for.
type resultShape uint8
const (
// shapeRead is material read forward — a fetched page, a parsed document, a
// recall map. The beginning is what was asked for, and the rest continues
// from an offset the notice states.
shapeRead resultShape = iota
// shapeCommand is what a command printed. The verdict is at the end: the
// compiler's error count, the test summary, the traceback, the exit reason.
// Head-truncating command output is the single most common way a tool loop
// throws away the only line that mattered.
shapeCommand
)
// spillRef names the file the whole result was preserved in, so a truncation
// notice can hand the model a command that works instead of an apology. The
// zero value means nothing was saved, which is a real case — the workspace may
// be read-only — and the notice says so rather than naming a path that is not
// there.
type spillRef struct {
path string
bytes int
partial bool // the file holds only the beginning of the output
}
// truncation is one over-long result decomposed into everything a notice has to
// state. It is built from the whole text (boundResult) or from the tail of a
// stream (cappedOutput.String), and both render through the same method, which
// is what keeps a streamed command's notice identical to a held one's.
type truncation struct {
kept string
keptLines int // 0 when not one whole line fit and a line had to be cut
// totalLines and totalBytes describe the whole output, which the streaming
// collector knows from counters rather than from the bytes it still holds.
totalLines int
totalBytes int
// byteWindow and lineWindow are the two caps that were applied, and they
// are in the struct because the continuation command is written from them:
// the next window is the same size as this one.
byteWindow int
lineWindow int
tail bool
spill spillRef
}
// countLines counts lines the way a line-addressing tool counts them: a
// trailing newline terminates the last line rather than starting an empty one,
// so `wc -l`, `sed -n '$p'` and this function agree about which line is last.
func countLines(text string) int {
if text == "" {
return 0
}
lines := strings.Count(text, "\n")
if !strings.HasSuffix(text, "\n") {
lines++
}
return lines
}
// truncateHead keeps the first whole lines that fit both caps. When not even
// the first line fits — a minified file, a one-line JSON dump — it keeps the
// first byteLimit bytes of that line and says which line it cut.
func truncateHead(text string, byteLimit, lineLimit int) truncation {
cut := truncation{totalBytes: len(text), totalLines: countLines(text)}
end, lines := 0, 0
for lines < lineLimit {
next := strings.IndexByte(text[end:], '\n')
if next < 0 || end+next+1 > byteLimit {
break
}
end += next + 1
lines++
}
if lines == 0 {
cut.kept = wholeRunesHead(text[:min(byteLimit, len(text))])
return cut
}
cut.kept, cut.keptLines = text[:end], lines
return cut
}
// truncateTail keeps the last whole lines that fit both caps — the tail rule,
// for output whose verdict is at the end. When the final line alone is over the
// byte cap it keeps that line's last bytes, because on a single-line output the
// end is still where the answer is.
func truncateTail(text string, byteLimit, lineLimit int) truncation {
cut := truncation{totalBytes: len(text), totalLines: countLines(text)}
search := len(text)
if strings.HasSuffix(text, "\n") {
// The trailing newline belongs to the last line; the line before it is
// where the search for a boundary starts.
search--
}
start, lines := len(text), 0
for lines < lineLimit && search >= 0 {
boundary := strings.LastIndexByte(text[:search], '\n')
begin := boundary + 1
if len(text)-begin > byteLimit {
break
}
start, lines = begin, lines+1
if boundary < 0 {
break
}
search = boundary
}
if lines == 0 {
cut.kept = wholeRunesTail(text[max(0, len(text)-byteLimit):])
return cut
}
cut.kept, cut.keptLines = text[start:], lines
return cut
}
// render puts the notice where the cut is: in front of a tail, because that is
// where the missing material was, and behind a head for the same reason. A
// model reading downwards meets the explanation at the seam either way.
func (c truncation) render() string {
if c.tail {
return c.notice() + "\n" + c.kept
}
return strings.TrimRight(c.kept, "\n") + "\n\n" + c.notice()
}
// notice is the whole of what truncation costs the model to recover from: what
// it is holding, of what, and the exact command that reads the rest.
//
// Exactness beats byte-stability here. These lines land in the transcript once,
// at the moment the result arrives, and are then re-sent as cached prefix like
// everything else — so a notice that differs per call costs nothing after that
// call, while a generic example ("for example: sed -n '1,80p' file") costs a
// wasted turn every time a model has to work out the real one.
func (c truncation) notice() string {
var head string
switch {
case c.keptLines == 0 && c.tail:
head = fmt.Sprintf("[Line %d of %d is longer than the %d bytes that fit; showing its last %d, of %d bytes in all.",
c.totalLines, c.totalLines, c.byteWindow, len(c.kept), c.totalBytes)
case c.keptLines == 0:
head = fmt.Sprintf("[Line 1 of %d is longer than the %d bytes that fit; showing its first %d, of %d bytes in all.",
c.totalLines, c.byteWindow, len(c.kept), c.totalBytes)
case c.tail:
head = fmt.Sprintf("[Showing lines %d-%d of %d, the last %d of %d bytes.",
c.totalLines-c.keptLines+1, c.totalLines, c.totalLines, len(c.kept), c.totalBytes)
default:
head = fmt.Sprintf("[Showing lines 1-%d of %d, the first %d of %d bytes.",
c.keptLines, c.totalLines, len(c.kept), c.totalBytes)
}
if c.spill.path == "" {
return head + " The rest was not saved — re-run narrowed (grep, head, tail) if you need it.]"
}
whole := "Whole output: " + c.spill.path
if c.spill.partial {
whole = fmt.Sprintf("First %d bytes: %s", c.spill.bytes, c.spill.path)
}
return head + " " + whole + " — " + c.command() + "]"
}
// command is the exact next call, against the file that exists, with the
// numbers this cut actually produced. The window it reads is the same size as
// the window that was kept, so a model that keeps issuing it walks the file at
// a pace its context can hold.
func (c truncation) command() string {
// K3/I0: the notice is shell input. Native separators otherwise disappear
// as escapes, and a separate scratch folder may contain shell punctuation.
// Unix command bytes stay unchanged at this platform conversion door.
path := filepath.ToSlash(c.spill.path)
if filepath.IsAbs(c.spill.path) {
path = commandshell.ListPath(c.spill.path)
}
if path != c.spill.path || filepath.Separator == '\\' && strings.ContainsAny(path, " \t\r\n'\"`$;&|<>()[]{}*?!#~") {
path = "'" + strings.ReplaceAll(path, "'", "'\"'\"'") + "'"
}
switch {
case c.keptLines == 0 && c.tail:
return fmt.Sprintf("read that line from the start with sh: sed -n '%dp' %s | head -c %d",
c.totalLines, path, c.byteWindow)
case c.keptLines == 0:
return fmt.Sprintf("read on with sh: sed -n '1p' %s | cut -b %d-%d",
path, len(c.kept)+1, len(c.kept)+c.byteWindow)
case c.tail:
first := c.totalLines - c.keptLines + 1
window := min(c.lineWindow, first-1)
return fmt.Sprintf("read the start with sh: sed -n '1,%dp' %s", window, path)
default:
return fmt.Sprintf("continue with sh: sed -n '%d,%dp' %s",
c.keptLines+1, c.keptLines+c.lineWindow, path)
}
}
// boundResult renders one whole result under both caps, and reports whether
// anything was cut. It is the held-in-memory twin of cappedOutput, which does
// the same arithmetic on a stream; the two must agree byte for byte, and a test
// holds them to it.
func boundResult(text string, carry, keep int, shape resultShape, spill spillRef) (string, bool) {
if len(text) <= carry && countLines(text) <= maxResultLines {
return text, false
}
if keep <= 0 || keep >= carry {
keep = carry / 2
}
var cut truncation
if shape == shapeCommand {
cut = truncateTail(text, keep, maxResultLines)
cut.tail = true
} else {
cut = truncateHead(text, keep, maxResultLines)
}
cut.byteWindow, cut.lineWindow, cut.spill = keep, maxResultLines, spill
return cut.render(), true
}
// toolBudgets is one leaf's four result bounds in bytes, resolved once.
//
// Once is the whole point and it is a cache property rather than a performance
// one. These numbers decide how long a result is, results are the bulk of the
// transcript, and a bound that moved mid-run would rewrite messages the provider
// is otherwise serving warm. They are settled when the toolbox is built, from a
// window that cannot change for the life of the leaf, and read from there.
type toolBudgets struct {
result int // one whole result, before clamp elides its middle
spill int // past this a result goes to a file instead
preview int // what stays in context when it does
recall int // the recall map, which is bounded tighter than the territory
}
// toolBudgetsFor turns the leaf's context window into its four bounds. A window
// nobody could name yields exactly the old literals — see ctxbudget: unknown is
// never treated as small, it is treated as unmeasured, and every consumer names
// what it used to do.
//
// The share is taken of the WORKING SET rather than of the raw window, because
// the memory that has to HOLD these results is taken of the working set — see
// observationWindow, working-set clamped since the fill law arrived. Sizing the
// result off one pot and the memory off a smaller one was a mismatch with a
// measured cost: on a 200k model a single sh result was permitted 75,696 bytes
// against a 105,856-byte observation window — 71% of the leaf's whole memory in
// one call — and on a 1M model the same call was permitted 795,696 bytes
// against that identical window, 7.5x more than could ever be held. Two results
// put the window over budget on arrival, so the decay pass fired every turn,
// rewrote the middle of the transcript every turn, and nothing behind the fade
// line was ever served warm again. That is a structural contributor to the
// 5.76x duplication this leaf was measured at.
//
// The convergence this introduces is the honest part rather than the cost: past
// the working set a result cap stops growing because the memory holding it
// stopped growing. It is a dial and not a belief — CODEAF_WORKING_SET raises
// the window and these four bounds together, which is the only coherent way to
// raise either.
func toolBudgetsFor(contextTokens int) toolBudgets {
budget := ctxbudget.For(contextTokens).WithinWorkingSet().WithFloor(observationFixedFloorTokens)
return toolBudgets{
result: budget.Share(maxToolResultBytes, toolBudgetReference, maxToolResultBytes),
spill: budget.Share(spillBytes, toolBudgetReference, spillBytes),
preview: budget.Share(previewBytes, toolBudgetReference, previewBytes),
recall: budget.Share(maxRecallResultBytes, toolBudgetReference, maxRecallResultBytes),
}
}
// Result is one tool's answer. A failure is a Result, never a Go error: the
// model has to see what went wrong to fix it, and aborting the loop over a
// mistyped path throws away every turn that came before.
type Result struct {
Content string
IsError bool
// reportedJobs says this result was footed with the state of the
// outstanding background jobs, which is the one part of it that is NOT a
// fact about the call: it carries an elapsed time and is stale the moment
// it is written. [observations.admit] reads it to take the report off
// before it addresses the body, and to refuse to address a result that is
// nothing BUT a report — two reports are never the same fact.
reportedJobs bool
// shape is which end of this result survives if it has to be cut. The
// default is shapeRead because most results are read forward; the tools
// whose output ends in a verdict say so. See resultShape.
shape resultShape
// bounded says this result already went through the caps and carries its
// own truncation notice — a streamed command, which had to be bounded as it
// arrived because nothing could hold the whole of it. Bounding it a second
// time at the turn boundary would cut the notice off the end of itself.
bounded bool
// timedOut carries the command runner's fact to the round limit so that
// deciding whether to stop never depends on matching person-facing text.
timedOut bool
// Followup carries multimodal content that must reach the next model turn.
// The ordinary text result is still emitted first so tool-call pairing
// remains valid on every OpenAI-compatible backend.
Followup []ai.ContentPart
Usage Usage
}
func errorf(format string, args ...any) Result {
return Result{Content: fmt.Sprintf(format, args...), IsError: true}
}
// Toolbox executes tool calls against one workspace on behalf of one node.
type Toolbox struct {
workspace *Workspace
// leaf is who this toolbox works for, and it names every file the leaf's
// machinery writes: the artifact bucket, the spilled observations, the
// background job logs. It is a string because the identity a caller holds is
// not always a number — see Task.NodeKey.
leaf string
web *Web
history *store.Store
media *MediaTools
jobs *jobRegistry
// budgets are what this leaf's model can afford to carry, fixed at
// construction and never recomputed. See toolBudgets.
budgets toolBudgets
// spills is atomic because a turn's tool calls execute concurrently, and
// two large results spilling at once must not race the counter into the
// same file name.
spills atomic.Int64
// armed names the optional capability families whose schemas this leaf is
// currently carrying. It is guarded because a turn's tool calls run
// concurrently and Definitions is read between turns.
armedMu sync.Mutex
armed map[string]bool
// armedOrder is the same set in the order it was armed, and it exists for
// the prompt cache rather than for bookkeeping. Emitting the armed schemas
// in a fixed order would insert a newly armed family in FRONT of one already
// in hand — a rewrite of the middle of the tool block, which re-bills every
// byte behind it — where emitting them in arrival order can only ever append
// at the tail. See Definitions.
armedOrder []string
// share is the worker's one-line channel to the rest of its job. Nil for a
// job with no siblings — the schema is only carried where somebody is
// listening. Installed from Task.Share at run start.
share func(line string) error
}
// The optional capability families, and the whole of why they are optional.
//
// Every definition is re-sent on every turn of every leaf. Measured on a
// six-cell benchmark, the media and document schemas were ~1,112 tokens of
// each leaf turn's ~3,778-token fixed floor — 18% of every input token the run
// spent — and not one of those cells could have used a single one of them. A
// bugfix cannot generate a video. A release note cannot read a PDF that does
// not exist.
//
// The answer is not to remove the tools; the resident's charter, watch and
// media journeys genuinely need them. It is to stop paying for them by
// default. A leaf carries the core loop plus one small tool that says what
// else exists; asking for a family arms it for the next turn and every turn
// after. The judgement of whether the work needs a camera stays with the model
// doing the work, made at the moment it knows — not predicted for it at
// compile time by a call that has never seen the workspace.
//
// The cost is honest and worth naming: a job that does need media pays one
// extra turn and one prefix-cache invalidation at the moment it arms. A job
// that does not — the overwhelming majority — pays nothing at all, ever.
const (
FamilyMedia = "media"
FamilyDocument = "documents"
)
// The tools each family carries. Arming is per tool rather than per family so
// a structural signal can admit exactly what it justifies: an attached
// screenshot is a reason to be able to look at an image, not a reason to be
// able to score a film.
var familyTools = map[string][]string{
FamilyMedia: {"generate_image", "generate_music", "generate_video", "speak", "view_image"},
FamilyDocument: {"read_document"},
}
// Arm admits named capability families or individual tools for the rest of
// this leaf's life. It is how a structural fact about the assignment — an
// attached image, an attached document — buys back exactly the schema it
// justifies before the first turn, and how the discovery tool answers a
// worker that asked.
func (t *Toolbox) Arm(names ...string) {
t.armedMu.Lock()
defer t.armedMu.Unlock()
if t.armed == nil {
t.armed = map[string]bool{}
}
for _, name := range names {
if tools, ok := familyTools[name]; ok {
for _, tool := range tools {
t.armOne(tool)
}
continue
}
t.armOne(name)
}
}
// armOne records one newly armed tool once, keeping the arrival order the tool
// block is emitted in. Called with armedMu held.
func (t *Toolbox) armOne(name string) {
if t.armed[name] {
return
}
t.armed[name] = true
t.armedOrder = append(t.armedOrder, name)
}
func (t *Toolbox) isArmed(name string) bool {
t.armedMu.Lock()
defer t.armedMu.Unlock()
return t.armed[name]
}
// armedInOrder is a snapshot of what this leaf holds, oldest first.
func (t *Toolbox) armedInOrder() []string {
t.armedMu.Lock()
defer t.armedMu.Unlock()
order := make([]string, len(t.armedOrder))
copy(order, t.armedOrder)
return order
}
// offered names the families this leaf could still arm — configured on the
// brain, and not already in hand. It is prose only: it phrases the error a
// worker reads when it calls a tool it has not got. It decides nothing about
// the tool list, because a tool list that moves when a family is armed is the
// defect this file spent a whole commit removing.
func (t *Toolbox) offered() []string {
var families []string
if t.media != nil && t.media.Provider != nil && !t.isArmed("generate_image") {
families = append(families, FamilyMedia)
}
if t.media != nil && t.media.DocumentClient != nil && !t.isArmed("read_document") {
families = append(families, FamilyDocument)
}
return families
}
// configuredFamilies says whether this machine has anything to arm at all. It
// reads the brain's wiring and never the armed set, which is exactly what makes
// it constant for the whole life of a leaf — and identical across every leaf of
// a run, since they all share one brain.
func (t *Toolbox) configuredFamilies() bool {
return t.media != nil && (t.media.Provider != nil || t.media.DocumentClient != nil)
}
// capabilitiesDefinition is the whole discovery surface: one tool, one
// argument, and an enum the code owns because the families are the code's own
// grouping of its own tools. The model reads its own work and decides; nothing
// here matches a phrase against the brief.
//
// Every byte of it is frozen, and that is the fix rather than the style.
//
// The description used to be assembled from whichever families were still
// unarmed, and the enum with it. Tool definitions ride at the front of every
// request, ahead of the entire transcript, so the moment a worker armed media
// the sentence describing the families changed, the prefix diverged at the tool
// block, and the whole prompt behind it was re-billed cold. Arming is supposed
// to cost exactly one invalidation — the new schemas appended at the end of the
// tool list, which is the price the design already accepted and named. This was
// a second, larger one nobody had costed, paid at the front instead of the back.
//
// So the enum stays full width too, including a family this machine may not
// have configured. Asking for one that is missing is answered by capabilities
// itself, in a sentence that tells the worker to do the job without it and say
// so — one wasted call in the rare case, against a definition block that never
// moves for any leaf on any turn.
//
// It no longer retires, either, and that was the last moving part. Retiring it
// once everything on offer was armed removed a definition from the MIDDLE of
// the tool list — every schema behind it shifted, so the turn that finished
// arming paid a second full-prompt invalidation on top of the one arming
// already costs. The tool is ~90 tokens; the block it was displacing is the
// whole prompt. So it stays for the life of any leaf whose machine has a family
// configured, and a worker that asks for something it already holds is answered
// in one cheap line — see capabilities.
func capabilitiesDefinition() ai.ToolDefinition {
return define("capabilities",
"Load tools you do not have yet; they arrive on your next turn. Ask once, only if the work needs one. "+
"media: generate images, music, video, speech; look at an image. "+
"documents: read a PDF, DOCX or PPTX into text.",
map[string]any{
"need": map[string]any{"type": "string", "enum": []string{FamilyMedia, FamilyDocument}},
}, "need")
}
// capabilities arms what was asked for and says what arrived. The reply names
// the tools rather than the family, because the next turn's schema list is
// what the worker will actually be holding.
func (t *Toolbox) capabilities(args map[string]any) Result {
need := strings.TrimSpace(stringArg(args, "need"))
tools, known := familyTools[need]
if !known {
// The families, not what is left to arm: the enum is the same on every
// turn now, so the correction has to name the same thing the enum does
// or the two disagree in front of the model.
return errorf("no capability family named %q. Available: %s, %s", need, FamilyMedia, FamilyDocument)
}
switch need {
case FamilyMedia:
if t.media == nil || t.media.Provider == nil {
return errorf("media generation is not configured on this machine — do what the assignment needs without it and say plainly in your answer that it could not be done")
}
case FamilyDocument:
if t.media == nil || t.media.DocumentClient == nil {
return errorf("document parsing is not configured on this machine — do what the assignment needs without it and say plainly in your answer that it could not be done")
}
}
// Already in hand: the cheap no-op that lets the definition stay in the
// prompt forever. A worker that asks twice costs one short tool result at
// the end of the transcript, which is appended and therefore free of any
// prefix invalidation; retiring the tool to prevent the second ask would
// rewrite the tool block instead, and that is paid for by every remaining
// turn of the leaf.
if t.holdsAll(tools) {
return Result{Content: "Already loaded — " + strings.Join(tools, ", ") +
" are in your tool list now. Use them; do not ask again."}
}
t.Arm(need)
return Result{Content: "Loaded for your next turn and every turn after: " + strings.Join(tools, ", ") +
". Their full descriptions are in your tool list from here on."}
}
// holdsAll reports that every named tool is already armed.
func (t *Toolbox) holdsAll(tools []string) bool {
for _, tool := range tools {
if !t.isArmed(tool) {
return false
}
}
return len(tools) > 0
}
// shareLine hands one line to the rest of the job. The write itself is the
// caller's closure — the toolbox knows nothing about where notes live, which
// is what keeps this package free of the store's job topology.
func (t *Toolbox) shareLine(args map[string]any) Result {
if t.share == nil {
return errorf("this work has no other workers to tell")
}
line := strings.TrimSpace(stringArg(args, "line"))
if line == "" {
return errorf("share needs the one line the others should read")
}
// One line means one line: a paragraph shared to every sibling is paid for
// in every sibling's every remaining turn.
if len(line) > shareLineBytes {
clipped := line[:shareLineBytes]
for len(clipped) > 0 && !utf8.ValidString(clipped) {
clipped = clipped[:len(clipped)-1]
}
line = clipped + "…"
}
if err := t.share(line); err != nil {
return errorf("could not pass that along: %v", err)
}
return Result{Content: "Passed along. The other workers read it between turns."}
}
// shareLineBytes bounds one shared line. It is a bound on cost, not on
// content: every byte here is re-read by every sibling on every remaining
// turn, so a note pays rent everywhere at once.
const shareLineBytes = 300
// NewToolbox builds a toolbox for a caller that cannot say what its model
// holds. That is not a guess about a small model — it is the honest unknown, and
// it resolves to the bounds this package has always used.
func NewToolbox(workspace *Workspace, leaf string, web *Web) *Toolbox {
return newToolbox(workspace, leaf, web, nil, nil, 0)
}
// NewToolboxWithStore adds persistent recall to the generic toolbox. A nil
// store deliberately collapses to NewToolbox so one-shot leaves retain the
// base-definition prompt.
func NewToolboxWithStore(workspace *Workspace, leaf string, web *Web, history *store.Store) *Toolbox {
return newToolbox(workspace, leaf, web, history, nil, 0)
}
// newToolbox is the one constructor, and contextTokens is the fact every other
// bound in this file is derived from. It is passed in rather than looked up for
// the same reason Linear.WithContextLength exists: the surface owns the catalog
// and hands facts down, and a leaf must never fail to run because a metadata
// endpoint was dark.
func newToolbox(workspace *Workspace, leaf string, web *Web, history *store.Store,
media *MediaTools, contextTokens int) *Toolbox {
budgets := toolBudgetsFor(contextTokens)
return &Toolbox{workspace: workspace, leaf: leaf, web: web, history: history, media: media,
budgets: budgets, jobs: newJobRegistry(workspace, leaf, budgets.result)}
}
// mediaModelArgDescription teaches the model argument in one breath: the slot
// default is right for routine work, and "best" is for the times quality is
// the point. It is resent every turn, so it stays one sentence.
const mediaModelArgDescription = `optional model: omit for the default, "best" when the user asked for quality or this is the final deliverable, or a model name`
// toolGuidelines is the standing guidance each tool contributes to the system
// prompt, keyed by the tool that owns it — and it is owned by the tool rather
// than by the prompt for one reason: a leaf that is not holding the tool must
// not be reading the instruction.
//
// The monolithic prompt cannot express that. Every leaf pays for every sentence
// in it, including the paragraphs about capabilities this leaf does not have
// and will never arm, and the only way to add guidance for one tool is to widen
// the text every other leaf reads. Assembling it from the toolbox that was
// actually built inverts that: two tools, two lines.
//
// What lives here is deliberately narrow — the behaviour of a tool that a model
// cannot infer from its schema and that costs a wasted turn to learn by doing.
// The prompt's own paragraphs are not moved here and must not be: they are the
// shared warm prefix, a sibling owns their wording, and rewriting them to prove
// a mechanism would re-bill every leaf in flight to say the same thing
// differently. This is the mechanism, carrying the lines the recent tool
// changes actually created.
var toolGuidelines = map[string]string{
"sh": "Long command output is cut to its LAST lines — where the error and the summary are — " +
"and the whole of it is saved to a file the result names. Read the part you need from that " +
"file with the command the notice gives you; do not re-run the command to see the beginning.",
"edit": "To change several places in one file, pass them all in one edit call as edits: every old " +
"is matched against the file as it is now, so you do not have to reason about how the earlier " +
"edits in the same call moved the text.",
}
// Guidelines returns the guidance lines for the tools this leaf is actually
// holding, in the order the tools are defined. It is read once, when the system
// message is assembled: the set of tools a leaf holds is fixed at that point
// except for arming, and arming appends schemas rather than rewriting the
// standing text — a system message that changed mid-run would cost the whole
// prompt at full price on the turn it changed.
func (t *Toolbox) Guidelines() []string {
var lines []string
for _, definition := range t.Definitions() {
if line, ok := toolGuidelines[definition.Function.Name]; ok {
lines = append(lines, definition.Function.Name+": "+line)
}
}
return lines
}
// Definitions are what the model sees. Descriptions are terse because they are
// resent every turn, but each one states the thing an agent gets wrong without
// being told.
//
// The ORDER is load-bearing and is the second half of the cache-shape fix. Tool
// definitions ride at the very front of every request, ahead of the whole
// transcript, so the first byte of this block that differs between two calls
// re-bills everything behind it at full price. The list is therefore built in
// three strata, widest agreement first:
//
// 1. the five tools every leaf on every machine always has, in a fixed order;
// 2. the discovery tool, present for the life of any leaf whose machine has an
// optional family configured — a property of the brain, never of what this
// leaf has armed, so it never appears or vanishes mid-run;
// 3. per-leaf conditionals (recall, share) and then the armed schemas.
//
// Only stratum 3 can move, and it can only ever grow at the tail: arming a
// family appends, so the invalidation is bounded by the schemas actually added
// rather than by everything that used to sit behind the thing that moved.
func (t *Toolbox) Definitions() []ai.ToolDefinition {
definitions := []ai.ToolDefinition{
define("sh", "Run a shell command in the workspace. Use it to read, list, search, and inspect. cmd is one command string (chain with && and pipes), or an array of commands run in order, stopping at the first failure. For INDEPENDENT commands, prefer separate sh calls in the same turn — they run at the same time. For servers, builds over a minute, or watch loops, set bg:true and use the job tool — do not block on them.", map[string]any{
"cmd": prop("string", "shell command, or an array of commands run serially"),
"t": prop("integer", "timeout seconds; default 60, or 900 with bg"),
"bg": prop("boolean", "start as a background job"),
}, "cmd"),
define("job", "Check or wait on background jobs. Prefer one wait over repeated checks. Compose monitors from bg shell loops (e.g. bg: until curl -s :8080/health; do sleep 1; done — then wait on it). Kill servers when done testing; anything still running dies with the leaf. To keep a server running after the task, use keep — never nohup.", map[string]any{
"id": prop("integer", "job id; omit to list jobs"),
"wait": prop("integer", "seconds to wait for exit, maximum 120"),
"kill": prop("boolean", "terminate the job process group"),
"keep": map[string]any{"type": "object", "description": "request promotion to a user-owned service", "properties": map[string]any{
"name": prop("string", "short service name"),
"health": prop("string", "port:5173, url:http://..., or cmd:..."),
}, "required": []string{"name", "health"}},
}),
define("write", "Write a file with exact content. Use this for any deliverable prose; never emit documents through sh.", map[string]any{
"path": prop("string", "workspace-relative path"),
"text": prop("string", "full file content"),
}, "path", "text"),
define("edit", "Replace exact text in a file. Pass edits to change several places in one call. Far cheaper than rewriting a long file to change part of it.", map[string]any{
"path": prop("string", "workspace-relative path"),
"edits": map[string]any{"type": "array", "description": "several replacements, each matched against the current file",
"items": map[string]any{"type": "object", "properties": map[string]any{
"old": prop("string", "exact text to replace, must appear once"),
"new": prop("string", "replacement text"),
}, "required": []string{"old", "new"}}},
"old": prop("string", "exact text to replace, must appear once"),
"new": prop("string", "replacement text"),
// Only the path is required, because there are two legal shapes: one
// old/new pair, or the edits array. A schema that demanded both would
// make the batched form invalid on its face.
}, "path"),
define("web", "Search the web, or fetch pages as text. Pass q to search. Pass urls to fetch several pages in one call, which is much faster than one at a time.", map[string]any{
"q": prop("string", "search query"),
"urls": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "page urls to fetch"},
"n": prop("integer", "max search results, default 6"),
}),
}
// An unavailable shell has no schema; direct callers still get the shared
// installation remedy before a process or background job can be created.
if _, err := commandshell.Bash(); err != nil {
definitions = definitions[1:]
}
// Stratum 2: the door to the optional families. Its presence follows the
// machine's wiring and nothing else, so it is in the same slot on every turn
// of every leaf of a run, or on none of them.
if t.configuredFamilies() {
definitions = append(definitions, capabilitiesDefinition())
}
// Stratum 3 begins here: everything below is per-leaf or per-arming, and is
// only ever appended.
if t.history != nil {
definitions = append(definitions, define("recall", "Search folded work and the notebook. Recall gives the map, not the territory: use the returned digest to choose what matters, then read the returned pointer paths with sh for the verbatim details. terms are free text; scope_cues are optional workspace or file paths.", map[string]any{
"terms": prop("string", "words describing the prior work or lesson"),
"scope_cues": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional workspace or file paths"},
"limit": prop("integer", "maximum fold and notebook hits, default 5, maximum 10"),
}, "terms"))
}
if t.share != nil {
definitions = append(definitions, define("share", "Tell the other workers on this job one line they need: a discovery about the material, a pitfall, a decision they must match. It reaches them between their turns. Only what changes how someone else acts — never progress reports, never your own status.", map[string]any{
"line": prop("string", "one sentence the rest of the job needs"),
}, "line"))
}
// The optional schemas ride the prompt only once this leaf has a reason to
// carry them: a structural one it was armed with before turn 1, or the
// worker's own request through the discovery tool.
//
// They are emitted in ARMING order, and that is the whole of what makes
// arming an append. A fixed order looks tidier and is wrong: with the media
// schemas written above read_document, a leaf that armed documents first and
// media second would have five definitions inserted IN FRONT of the schema
// it was already carrying, which is a rewrite of the middle of the block and
// costs the entire transcript behind it. Emitting in the order they arrived
// means the newest schema is always last, whatever the route in.
//
// Each optional schema is admitted on its own name, not on its family's,
// so an attached screenshot buys view_image without also buying a video
// generator it has no use for.
catalog := t.optionalDefinitions()
for _, name := range t.armedInOrder() {
if definition, available := catalog[name]; available {
definitions = append(definitions, definition)
}
}
return definitions
}
// optionalDefinitions is every schema this machine could arm, keyed by name.
// A family the brain has not wired produces no entries, so a leaf that armed a
// tool the machine cannot serve carries no schema for it — and is answered in
// prose by capabilities instead.
func (t *Toolbox) optionalDefinitions() map[string]ai.ToolDefinition {
catalog := map[string]ai.ToolDefinition{}
if t.media == nil {
return catalog
}
if t.media.Provider != nil {
for _, definition := range []ai.ToolDefinition{
define("generate_image", "Generate one or more images into the workspace media directory. reference_paths may name existing workspace images for image-to-image work.", map[string]any{
"prompt": prop("string", "what to generate"),
"n": prop("integer", "number of images, default 1, maximum 10"),
"size": prop("string", "optional image size or aspect ratio"),
"model": prop("string", mediaModelArgDescription),
"reference_paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "workspace image paths to use as references"},
}, "prompt"),
// No format and no duration: the composing endpoint takes neither,
// and the format argument that used to be here promised a choice
// the wire never offered (internal/provider/music.go).
define("generate_music", "Compose music into the workspace media directory. The prompt describes the music — genre, instruments, tempo, mood — not lyrics to sing and not text to read out; use speak for a voiceover. There is no length argument: the model writes a piece of its own choosing, half a minute to a minute in practice, and the call costs the same however long it turns out — measure the file before laying it under anything timed.", map[string]any{
"prompt": prop("string", "a description of the music to compose"),
"model": prop("string", mediaModelArgDescription),
}, "prompt"),
define("generate_video", "Generate a video into the workspace media directory. This call waits for the asynchronous provider job to finish, for up to ten minutes. The first two reference_paths become first/last frames; any remaining images are style references. Every call is an independent render — the video model sees only this prompt and these images, so anything that must hold across clips must be described or passed as an image every time, and one clip continues from another only when the earlier clip's final frame is this call's first reference.", map[string]any{
"prompt": prop("string", "what to generate"),
"duration": prop("integer", "optional duration in seconds"),
"resolution": prop("string", "optional resolution such as 480p, 720p, or 1080p"),
"aspect_ratio": prop("string", "optional aspect ratio such as 16:9"),
"model": prop("string", mediaModelArgDescription),
"reference_paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "workspace image paths for first/last frames and style guidance"},
}, "prompt"),
define("speak", "Synthesize speech as an MP3 in the workspace media directory.", map[string]any{
"text": prop("string", "text to speak"),
"voice": prop("string", "optional voice, default alloy"),
"model": prop("string", mediaModelArgDescription),
}, "text"),
define("view_image", "Look at a workspace image. If the current model cannot see, a vision model looks and reports back — pass question for a targeted check.", map[string]any{
"path": prop("string", "workspace-relative image path"),
"question": prop("string", "optional targeted question about the image"),
}, "path"),
} {
catalog[definition.Function.Name] = definition
}
}
if t.media.DocumentClient != nil {
catalog["read_document"] = define("read_document", "Read a PDF into text. Free and local when possible; scanned documents escalate to OCR through the rail. Repeat reads are cached.", map[string]any{
"path": prop("string", "workspace-relative PDF, DOCX, or PPTX path"),
"pages": prop("string", "optional PDF page range such as 1-5"),
"question": prop("string", "optional question the worker will answer from the extracted text"),
}, "path")
}
return catalog
}
func reflexPromotionDefinition() ai.ToolDefinition {
return define("promote", "Stop this reflex and hand the original request to a full job. Use immediately when the action is not one obvious reversible step. partial says what you learned or changed before stopping.", map[string]any{
"partial": prop("string", "useful partial result or discovery for the full job to build on"),
}, "partial")
}
// requestSplitDescription is the whole of the ownable-subject test, in the one
// place a model reads before deciding.
//
// The test is written as a thing to try rather than as a definition, because
// every definition of "independent" a prompt has offered was agreed with and
// then ignored: a model asked to divide will divide, and the cheapest division
// is the procedure it was about to follow, relabelled as parts. The
// knowing-nothing clause is what that relabelling cannot survive — "write the
// report" cannot be started by someone who has not seen "gather the data", and
// saying so out loud is what makes the model notice.
//
// The terminal sentence is not a warning, it is the contract. A leaf that
// requested a split and then kept working would produce a partial its own
// children were planned against, and the two would be the same work paid for
// twice.
const requestSplitDescription = "Say that this assignment is really several separate jobs, and hand them over. " +
"Use it only when working on it has REVEALED that — a directory that turned out to hold twelve independent " +
"cases, a question that turned out to be four unrelated questions. " +
"The test for a part: could one agent take it from start to finished knowing nothing of what the other parts " +
"produced? If a part needs another part's output, or reads as a stage of one procedure — gather, then analyse, " +
"then write up — it is not a part, and this assignment is one job. Two parts minimum. " +
"Calling this ENDS your run immediately: you do not continue afterwards, the parts do. " +
"Everything you have already produced is handed to them, so say it in your reply first if it is not written down."
// requestSplitDefinition is the cooperative division tool. It is on the belt
// only in swarm mode, and it is added by Linear rather than by the toolbox for
// the same reason promote is: the call is intercepted and never executed, so a
// Toolbox entry for it would be a handler that can never run.
func requestSplitDefinition() ai.ToolDefinition {
return define("request_split", requestSplitDescription, map[string]any{
"parts": map[string]any{
"type": "array",
"description": "the separate jobs this assignment turned out to hold — at least two",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": prop("string", "what this part is called, a few words"),
"summary": prop("string", "one line saying what this part is for"),
"brief": prop("string", "the whole assignment for this part, written for an agent that will read nothing else"),
},
"required": []string{"title", "summary", "brief"},
},
},
"evidence": prop("string", "what you read or found that revealed the division — the file, the count, the shape of the thing"),
}, "parts", "evidence")
}
// requestedSplit reads the leaf's division out of a turn's calls. The
// request_split call is intercepted by Linear and never reaches the toolbox.
//
// Arguments that will not parse still end the run. The model has said the
// assignment is several jobs, and that finding is the expensive half; a request
// that arrives malformed is refused by Valid one layer up, which delivers the
// partial — the same ending a governor refusal gives it, and a far better one
// than handing the loop back to a worker that has just announced it is stopping.
func requestedSplit(calls []ai.ToolCall) (*SplitRequest, bool) {
for _, call := range calls {
if call.Function.Name != "request_split" {
continue
}
var args struct {
Parts []struct {
Title string `json:"title"`
Summary string `json:"summary"`
Brief string `json:"brief"`
} `json:"parts"`
Evidence string `json:"evidence"`
}
if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
return &SplitRequest{}, true