Repository navigation
Expand file tree
/
Copy pathagent.go
More file actions
5155 lines (4930 loc) · 240 KB
/
Copy pathagent.go
File metadata and controls
5155 lines (4930 loc) · 240 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 session
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/Agent-Field/agentfield/sdk/go/ai"
"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/effort"
"github.com/Agent-Field/codeaf/internal/env"
procexec "github.com/Agent-Field/codeaf/internal/executor"
"github.com/Agent-Field/codeaf/internal/guard"
lanes "github.com/Agent-Field/codeaf/internal/lane"
"github.com/Agent-Field/codeaf/internal/modelsource"
"github.com/Agent-Field/codeaf/internal/provider"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/trace"
)
// defaultContextWindow is the window assumed when Config.ContextWindow is
// zero. 128k is the smallest window among the models this surface routes to,
// so assuming it compacts a little early on a larger model and never
// overflows a smaller one — the safe direction for a guess to be wrong in.
const defaultContextWindow = 128_000
// providerTimeout bounds one non-streaming completion. Only the compaction
// summary takes that path (every turn request streams, and the adapter's
// stream client carries no total deadline by design), so this is a backstop
// against a wedged summarizer rather than a limit on how long a turn may run:
// the turn's authority is its context, which Interrupt cancels.
const providerTimeout = 10 * time.Minute
// closeGrace is how long Close waits for a cancelled turn to land its last
// messages. Two seconds is generous for the journal appends a cancelled turn
// still owes and short enough that a quit still feels like one.
const closeGrace = 2 * time.Second
// jobShutdownGrace is how long Close gives every running background job,
// together, to answer a SIGTERM before it is killed. It matches closeGrace for
// the same reason: a quit that takes four seconds already feels like a hang,
// and a dev server that has not shut down in two is not going to.
const jobShutdownGrace = 2 * time.Second
// New builds one session agent against a live provider. It performs no
// network request: the client is constructed, the prompt rendered, and the
// session file — if configured and present — replayed into the transcript.
func New(config Config) (*Agent, error) {
// The session keeps the service-qualified identity a person chose, with only
// the thinking level removed. The client keeps the bare wire slug. Folding
// both into settings.Model would make a later disconnect unable to tell
// which service this conversation was using.
launchModel := config.Model
config.Model, _ = roles.SplitEffort(config.Model)
// AND THE REQUEST ROAD IS THE CALLER'S WHEN IT SAID ONE. A caller that has
// already resolved its provider — the bash-belt worker seat, handed this
// conversation's account-aware completer — hands it in rather than have one
// minted from settings the seat does not carry; every other caller leaves it
// nil and New builds it here.
client := config.completer
if client == nil {
built, err := newProviderClient(config, launchModel)
if err != nil {
return nil, err
}
client = built
}
// AND THE OFFER DESK IS POINTED AT THE SIDE THAT HOLDS THE OPEN QUESTIONS,
// at the same moment and for the same reason: this is where a real transport
// exists. A pinned lane that goes quiet raises `coreweave is slow · switch to
// auto? (y)` on the phase channel; `y` comes back through this package, and
// without this line it would come back to nobody (phasenews.go).
SetOfferAnswerer(answerOffer(provider.AnswerOffer))
agent, err := newAgent(config, client)
if err != nil {
return nil, err
}
agent.manageClient(config)
return agent, nil
}
// newChildAgent is the production door for another Agent inside this session.
// Tests keep using newAgent with scripted completers; real workers share the
// parent's account-keyed pool and resolve their own model before their first
// request, rather than inheriting whichever adapter the parent last used.
func (a *Agent) newChildAgent(config Config) (*Agent, error) {
config.knowledgeReadVault = a.Knowledge()
client, account, pool, managed, err := a.childClient(config)
if err != nil {
return nil, err
}
child, err := newAgent(config, client)
if err != nil {
return nil, err
}
child.managedClient = managed
child.clientAccount = account
child.clientPool = pool
return child, nil
}
// newAgent is the seam New and the tests share: everything except which
// Completer the turns run against.
func newAgent(config Config, client Completer) (*Agent, error) {
if strings.TrimSpace(config.Workspace) == "" {
return nil, errors.New("session: workspace is required")
}
if strings.TrimSpace(config.Model) == "" {
return nil, errors.New("session: model is required")
}
if client == nil {
return nil, errors.New("session: completer is required")
}
if err := configureOwnedChat(&config); err != nil {
return nil, fmt.Errorf("prepare conversation storage: %w", err)
}
if config.newerBuild == nil {
config.newerBuild = buildinfo.StaleNotice
}
// Settle the launch preference before building either the page or belt.
// Explicit pins remain fixed; automatic profiles follow the selected
// window at later request boundaries (promptprofile_live.go).
config.profile = settlePromptProfile(config)
_, pinned := promptProfileWord(env.Get(promptProfileEnv))
_, chosen := promptProfileWord(config.PromptProfile)
config.liveProfile = &livePromptProfile{auto: !pinned && !chosen}
if !config.liveProfile.auto {
config.liveProfile.launch = chosenPromptProfile(config)
}
config.liveProfile.current.Store(config.profile)
system, own := config.System, false
if strings.TrimSpace(system) == "" {
system, own = renderSystem(config), true
}
if config.WorkspaceURI != "" {
system += "\n\nWORKSPACE STORAGE\nUse mktemp or $TMPDIR for temporary files and $XDG_CACHE_HOME for ordinary caches. These locations are outside the files that a task lands. Keep task deliverables in the assigned working directory. The directory is an assignment, not a shell sandbox; external and remote writes still need an explicit purpose."
}
if watch, ok := config.Seals.(interface{ TrackTurn(string) }); ok && config.Place.Dir != "" && !config.InTask && !config.Errand {
watch.TrackTurn(config.Place.Dir)
}
agent := &Agent{
taskComputerPrompt: config.mayNameTaskComputer(),
config: config,
system: system,
// STAMPED ONLY WHERE WE RENDERED IT. A prompt handed in by a caller is
// theirs, and re-rendering ours over the top of it later would be this
// file deciding what another door's worker is told.
systemAt: time.Now(),
systemOwn: own,
// THIS LAUNCH'S WALL CLOCK, and the only one this package keeps
// ([Agent.startedAt] says why the summed turn durations are not it).
startedAt: time.Now(),
model: config.Model,
// A memory-only session still has ONE lineage; it just has no name on
// disk to derive it from. The file-backed case overwrites this below
// with the header's id, which survives every resume.
id: NewSessionID(),
}
agent.presentation = &presentationIndex{}
agent.cacheKey = sessionCacheKey(agent.id)
// Start with no session-local endpoint limit; the provider applies its
// durable evidence when it encodes a request.
agent.noteModelWindow(agent.model)
// AND WHO THIS SESSION IS WORKING FOR, before anything else is built
// (principal.go). It is written once here and never again, which is what
// lets every road that consults it read the field without the lock; the
// posture that picks it — attended or not, with a ceiling or without — is
// entirely the door's, and a config that says nothing gets the [Person] this
// package has always answered to.
agent.principal = newPrincipalFor(agent)
// Memory is built before the belt for the same reason the registry is: the
// belt carries `remember` and `forget` only when there is a brain to write
// into, so the store has to exist before the tools are assembled (memory.go).
// The background lifetime is minted with it, because a pass started by the
// first turn has to have somewhere to be cancelled from. It is built on
// every prompt profile: a person's lines hold over a small model too.
if config.Memory != nil {
agent.memory = newMemoryBrain(config.Memory)
bindMemoryLedger(config.Place, agent.tellWrote)
agent.memoryCtx, agent.memoryStop = context.WithCancel(context.Background())
}
// AND THE NAMER'S OWN LIFETIME, minted for every session because every
// session may name itself and the errand starts on the first message rather
// than at the end of a turn (title.go).
agent.titleCtx, agent.titleStop = context.WithCancel(context.Background())
// The registry is built before the belt because the belt closes over it:
// bash's background path and the jobs tool are both views onto this one
// object, and it is the agent's own steering queue they report into.
// The registry gets both note lanes. A job's exit is work the model started
// and is waiting to hear finish, so it remains owed at the next step
// boundary. A watch's deltas are ambient telemetry: they stay live in the
// job row and log, but wait for a turn boundary before entering the model's
// context (see [Agent.enqueueJobNote] and [Agent.enqueueWatchNote]).
// The registry is handed the DROPPINGS home rather than the Place, and the
// two differ for every agent that is not a session: a worker's job log
// belongs beside the transcript of the conversation that commissioned it, not
// in the repository it borrowed to work in (landing.go).
agent.jobs = newJobRegistry(config.Workspace, config.droppingsPlace(), agent.enqueueJobNote, agent.enqueueWatchNote)
agent.jobs.seat = config.seat()
agent.jobs.degradedNotice = agent.sayJailLimit
agent.jobs.lifecycle = lifecycleOf(config.Machine)
// And the registry gets the ROSTER lane as well as the waking one. A job is
// work this conversation started, so it shows on the right the way every
// other kind of work does — a quiet row while it runs, settled when it ends
// (jobrow.go). It is set here rather than passed to the constructor because
// it closes over the agent the constructor is building.
agent.jobs.announce = agent.announceJobRow
// And the registry gets the RELEASE lane, for the same reason and by the same
// route: a command this agent started in the foreground and had taken over
// into a job is one the work is still waiting for, so the registry says when
// that ending has been handed over and whoever is parked on it goes and reads
// it (task_job_park.go). A conversation arms no park and this is never called
// with anybody waiting.
agent.jobs.paid = agent.releaseParkedOnJob
// And the accounts seam before the belt for the belt's own reason: the two
// connect tools are on it only when there is something behind them, so the
// hub has to exist before the tools are assembled (connect.go).
agent.connect = newConnectHub(config)
agent.initKnowledge()
agent.tools = agent.belt()
definitions, err := toolDefinitions(agent.tools)
if err != nil {
return nil, err
}
agent.definitions = definitions
// AND THE GROUPS THIS PROFILE HANDS OVER RATHER THAN ASKING FOR. A lean belt
// is given `ask` at construction because a one-call-per-message model cannot
// do load-then-ask inside a turn (promptprofile.go's [Config.prearmedGroups]
// states the whole of it). It goes on through [Agent.armFamily], the one
// arming door, so the append law and the dedupe are the same ones a
// connected account and a loaded group ride.
if err := agent.armPrearmed(); err != nil {
return nil, err
}
agent.messages = []ai.Message{textMessage("system", system)}
agent.messageReasoning = make([]provider.MessageReasoning, 1)
agent.refreshSystemLocked()
if strings.TrimSpace(config.SessionFile) != "" {
// The folder's name is the id a fresh journal's header takes (place.go):
// the session was named before the directory that holds it existed, and
// the header repeats that name rather than minting a second one. A
// legacy flat session hands "" and the file names itself, exactly as it
// always did.
file, replayed, err := openSessionFile(config.SessionFile, config.Workspace, config.Model, config.Place.ID())
if err != nil {
return nil, err
}
restored := replayed.messages
agent.file = file
file.onWrite = agent.tellWrote
agent.presentation = file.presentation
agent.restoreProgramHold()
// AND WHAT AN EARLIER PROCESS OF THIS SESSION MADE. It is the one thing
// in the journal that cannot be re-derived from the transcript — whether
// a file was there before the session touched it is a measurement, taken
// once, at the moment of the call — so a resumed session carries it
// forward rather than starting the sweep's list empty (principal_audit.go).
agent.createdFiles = replayed.created
// A resumed session keeps the name it was given: the title is a fact
// about the conversation in the file, and re-deriving it from the same
// opening exchange would pay for an answer we already have.
agent.title = file.Title()
// And it keeps its cache lineage for the same reason, which matters
// more: a session resumed tomorrow re-sends the transcript it built
// today, and a key that changed with the process would ask the router
// for a fresh replica and pay to write that whole prefix again.
if id := file.ID(); id != "" {
agent.id = id
agent.cacheKey = sessionCacheKey(id)
}
// The system message is rendered fresh rather than replayed: the date
// and AGENTS.md in the footer are facts about now, not about the
// session that wrote the file.
agent.messages = append(agent.messages, restored...)
agent.messageReasoning = append(agent.messageReasoning, replayed.reasoning...)
// AND WHETHER THE CONVERSATION ENDS ON A QUESTION NOBODY ANSWERED. It is
// carried off the journal rather than derived from the transcript because
// the fact lives on a line the transcript does not keep — which door
// stopped the turn (resume.go).
agent.stoppedTurn = replayed.stopped
// AND WHICH OF THOSE LINES THE PERSON ACTUALLY TYPED, which the messages
// alone cannot say (admission_compile.go). Work handed out of a reopened
// session would otherwise carry none of the conversation that preceded
// the restart, silently. No lock is taken for the reason nothing else in
// this constructor takes one: the agent is not reachable yet.
agent.restorePersonTurnsLocked(restored)
// AND THE TOOL GROUPS AN EARLIER PROCESS OF THIS CONVERSATION LOADED.
// The belt above was rebuilt from scratch, so the rarely-reached
// families are back on their shelf — while the transcript just restored
// still says "Loaded: settings, change_setting". Without this the model
// would reach for what its own history says it holds and be answered
// `Unknown tool`, which is the defect beltfacts.go exists to prevent.
// The record is the journal's own `load_capability` calls; nothing is
// stored beside them (tools_capabilities.go).
agent.rearmLoadedCapabilities(restored)
// AND THE CONVERSATION ABOVE THE LATEST COMPACTION IS SHAPED HERE, ONCE,
// while the replayed messages are still in hand. It is shaped rather than
// kept as messages so the pictures a compacted region held are let go of
// again — a display entry keeps the PATH, not the data URL — and shaped
// HERE rather than on demand because this is the only moment the region
// exists at all: nothing after this point re-reads the file, and holding
// the raw messages until somebody scrolled would hold every compacted-away
// photo in memory to pay for a shaping that costs one walk.
//
// The floor is shaped from the same messages for the same reason it is a
// count of ENTRIES rather than of messages: the surface it is for indexes
// entries, and a second rule for how many rows a message makes is a rule
// that can disagree with [shapeEntries]. The journal counts messages; this
// is the one place the two are converted, by doing the shaping.
agent.earlier = shapeEntries(replayed.earlier, file)
agent.earlierFloor = len(shapeEntries(restored[:replayed.overlap], file))
// AND THE PICTURES ARE MADE SAFE HERE RATHER THAN IN THE REPLAY. A
// session resumed onto a model without vision — the journal remembers
// the model it was written on, the person can start it on another — would
// otherwise re-send yesterday's base64 to a model that cannot read it,
// which is [Agent.SetModel]'s hole through the other door. The scrub is
// the same function both doors call, and it is done HERE because
// [replaySessionFile] is a pure function of the file: which model this
// session will ride, and whether it can see, are facts about the agent,
// and threading a capability closure into a file parser would put the
// question in the one layer that cannot answer it. No lock is taken for
// the reason nothing else in this constructor takes one — the agent is
// not reachable yet.
agent.scrubBlindImagePartsLocked(agent.model)
// AND IT KEEPS WHAT IT SPENT. The total is the sum of the file's usage
// lines (sessionfile.go), so a conversation reopened tomorrow reports the
// money it actually cost rather than the money this process has spent so
// far — which for a resumed session was always nothing.
//
// THIS ALSO RESTORES THE SPEND RAIL, and that is the intended change: the
// rail is this conversation's own ceiling (rail.go), so a conversation
// that reached it yesterday is still over it today. A rail that reset with
// the process was a ceiling on a window, not on a conversation.
agent.usage = file.RestoredUsage()
// AND A JOURNAL WRITTEN BEFORE THE TOTAL WAS KEPT GETS ONE NOW. The sum
// above came out of the file's own usage lines, so this costs the read
// of a meta.json and — only when it has no figure at all — one write
// (placemeta.go's [Agent.stampRestoredSpend]). Without it a conversation
// that has not spoken since the field arrived would read as free on
// home forever.
agent.stampRestoredSpend(agent.usage)
}
// AND WHERE THIS CONVERSATION IS ABOUT. The referred places ride on the same
// meta.json everything else about the session's identity does (places.go), so
// a conversation reopened tomorrow still knows the folders it turned out to
// be about and the ground ladder still answers for them without asking
// anybody again. It is read here rather than at the door because there is
// only one road in and no launch flag names it; a session with no folder — a
// headless run, a task node, a test — reads nothing and accrues nothing.
agent.places = loadPlaces(config.Place.Dir)
// AND THE MODEL IS TOLD ABOUT THEM BEFORE THE FIRST REQUEST. This is what
// makes an attachment survive a restart in the only sense that matters: a
// conversation reopened tomorrow does not merely REMEMBER the folder, its
// next request names it (placescontext.go). It is composed here rather than
// at the top of the constructor because the set is only read on this line,
// and it is [Agent.keepAttached] rather than a field write so that the one
// composition rule lives in one place. The agent is not reachable yet, so the
// lock it takes is uncontended.
agent.keepAttached()
// AND THE WORK THAT HAS NOT LANDED YET. A conversation closed with changes
// waiting in its own copy of a folder comes back holding them, and the
// composer's chip says so again (standingtree.go). A record whose copy is no
// longer on disk is dropped on the way in, so what is read back is what can
// actually be landed.
agent.trees = loadStandingTrees(config.Place.Dir)
// THE THREAD IS THE SESSION'S OWN ID, and it is minted nowhere: the journal
// header already carries one that survives every resume, the folder is named
// by the same string, and a memory-only session has the one this constructor
// minted for its cache lineage. Deriving a second identity here would give
// one conversation two threads the day somebody resumed it (chatlog.go).
// And it takes the DROPPINGS home for the registry's reason: the only thing
// the journal does with a Place is spill an over-long message's bytes through
// [writeStub], which is a dropping like any other (landing.go).
// THE JOURNAL INDEXES INTO THE CONVERSATION INDEX, which is the memory
// store for every pre-split caller and its own store where a door opened
// one — so a memory store that would not open still leaves the
// conversation searchable (tools_conversations.go states the order).
agent.chatlog = newChatJournal(config.conversationIndexStore(), agent.threadID(), config.Workspace, config.droppingsPlace())
// And the state card is held before any turn has run, so that the note the
// first request carries already has it: a resumed conversation's card is what
// it knew yesterday, and a model that had to wait for the first post-turn
// pass to be told would answer one question in the dark (card.go).
agent.cardText = agent.stateCardText()
// The client is wrapped LAST, once the lineage is known: the wrapper is the
// one place every request this agent makes passes through, so it is where
// the prompt-cache key is stamped. Wrapping earlier would have to read the
// key through the agent, which is a pointer cycle to save a line.
// The account door owns the field itself, while the construction facts stay
// here beside the lifecycle they describe.
agent.installSessionClient(client, config)
// AND THE WORK IS RECOVERED LAST, once this agent can actually run one. A
// resumed journal may have a task graph beside it — nodes that landed, a node
// that was still running when the process died, nodes waiting on them — and
// recovery is load, reconcile with the disk, continue the frontier
// (task_store.go). A fresh session has no checkpoint and this is a stat.
agent.recoverTasks()
// AND A PROGRAM'S RUN THE LAST PROCESS LEFT OPEN IS ENDED, where it was last
// seen, so its page stops reading `running` (task_run_belt.go).
agent.recoverBeltRun()
agent.endInterruptedProgramRun()
// AND THE PROJECT'S RECORD IS RECONCILED BESIDE IT. The checkpoint above is
// one conversation's graph; the project index is every window's record of
// what this directory ever ran, and it holds rows that say "running" — a run
// takes one the moment it starts (orchestrate.go). A process that died owes
// those rows a closing one, and this is the moment anybody can know it is
// owed. It runs AFTER recovery so that a node the graph took back is not
// closed out from under it.
agent.closeInflightTaskIndexRows()
// AND THIS PROCESS SAYS IT HOLDS THIS CONVERSATION. It is how a lane knows
// somebody is waiting on it, and how the memory pass finds the one room to
// say its line in (livesessions.go). Close erases it.
registerLiveSession(agent)
// AND WHAT WAS ANSWERED WHILE NOBODY WAS HOME IS ANSWERED NOW. An answer left
// on this conversation's doorstep rides the presence heartbeat
// (answers.go's [Agent.drainAnswers]), which is the right beat for one
// window answering another that is RUNNING. It is the wrong one for the
// conversation that was NOT: a window shut for the night keeps nothing
// beating, so the answer the person left on home — accept this landing, no
// to that card — sat in answers.jsonl until the next time anybody happened
// to open the conversation, and even then until the first tick of the new
// process. From home's side the row said `answered · waiting for it to pick
// that up` for as long as the person cared to look, which is home saying it
// answered and home being wrong. The graph the landing answers to is back
// above, and the heartbeat that would race this drain starts below, so the
// doorstep is emptied here: the answer is applied through the same one door
// as every other, and the conversation opens already settled rather than
// asking a question somebody answered yesterday.
agent.drainAnswers()
// AND THE SESSION STARTS SAYING IT IS HERE. The index above is what work
// came to; this is the claim that a PROCESS is alive right now, which no
// file on disk could otherwise make (taskpresence.go). It is last of the
// three because it describes the state the two lines above just settled.
agent.startPresence()
// AND THE LANE SHEET STARTS BEATING FOR THIS SESSION'S MODELS. It is the one
// thing under here that goes to the network without a person asking, which
// is why it is the thing most carefully gated: see [Agent.startLaneBeat] for
// the three sessions that run no beat at all.
agent.startLaneBeat()
// AND FURROW IS ASKED ABOUT THIS FOLDER, from the background. Opening the
// conversation must not wait on a subprocess, so the answer is started here
// and read later by the belt (tools_workspace.go).
agent.askFurrow()
// AND ONLY NOW MAY IT SPEAK UNPROMPTED. Recovery turns the frontier, and a
// cascade over the dependents of an interrupted node settles them right here,
// inside New — before the caller holds the agent, before any surface has
// subscribed to anything. A turn started at that moment would be answered
// into a room that does not exist yet: journaled, paid for, and never drawn.
// Everything recovery has to say is queued instead, and the first turn reads
// it (see [Agent.wakeLocked]).
agent.mu.Lock()
agent.opened = true
agent.mu.Unlock()
// THE WALL IS READ AFTER THE SESSION IS OPEN. Recovery may put work back on
// the frontier, but no ending or notice may run before the caller can hold
// the agent and a surface can subscribe to its standing lane.
agent.armWallClock()
// AND A CONVERSATION A TEAM'S MANAGER STARTED TAKES ITS FIRST TURN ON ITS
// OWN, once the interface has made it a member (team_wake.go).
agent.watchTeamStart()
agent.startKnowledge()
return agent, nil
}
// ── THE LANE-SHEET BEAT ─────────────────────────────────────────────────────
// startLaneBeat begins this session's lane-sheet beat, or does nothing at all.
//
// The router publishes, per model, one row per machine serving it — first-token
// latency and throughput over the last half hour — and that is a free prior for
// every lane this session might be sent to, so nothing is blind on the first
// call (docs/ARCHITECTURE.md, Decision 10). `internal/lane` decodes it and
// primes the belief from it, but it starts no goroutine of its own by design:
// who is fetching, and when, is a question with an answer in the session's own
// code rather than in a package nobody thought was running.
//
// TWO SESSIONS RUN NO BEAT, and each refusal is a different fact.
//
// - ROUTING OFF is a person saying they do not want their endpoints chosen
// for them (internal/provider's velocity.go). With the row off, every lane
// the belief holds is inert — nothing reads it, nothing is sent from it —
// and a background fetch would be work nobody asked for on somebody who
// asked for the opposite.
// - AND A SESSION WITH NO MODEL SLOT FILLED has nothing to fetch a sheet
// about, which is the constructor's own guard reaching this far.
//
// THE BASE URL IS NOT A REFUSAL. This seam used to read the hostname and run
// no beat unless it said `openrouter.ai`, which left every session pointed at
// a proxy, a mirror or a router reached by its IP with no sheet at all (issue
// #373). Whether a base publishes an endpoints page is the base's own to say:
// the sheet asks it once, on the first refresh, and a base that says there is
// none is left alone until that answer is stale ([lanes.ErrNoSheetHere]). So
// the beat runs whenever routing is on, and on a base with no page it is one
// quiet request every five minutes rather than a feature that is absent.
//
// It is called once, from the constructor, before the agent is reachable —
// which is what lets the field it writes be read afterwards without a lock,
// exactly as [Agent.presence] is.
func (a *Agent) startLaneBeat() {
// THE SESSION'S LANE CONTEXT IS MINTED FIRST AND UNCONDITIONALLY, above
// every refusal below, because it is not the beat's: a probe rides it too
// (lanenews.go's [Agent.Typing]), and a session that runs no beat may still
// buy a measurement. It is cancelled once, by Close.
a.laneCtx, a.laneStop = context.WithCancel(context.Background())
if a.config.routingInForce() == provider.RoutingOff {
return
}
models := laneBeatModels(a.config)
if len(models) == 0 {
return
}
ctx := a.laneCtx
// THE INTERVAL IS THE LANE PACKAGE'S OWN AND IS NOT RESTATED HERE. Zero asks
// [lanes.Beat] for its default, which is the same five minutes that package
// already publishes as the age at which a cached sheet is stale — and a
// second spelling of that number here is a number that would drift, so that
// one session fetched on a clock the cache disagreed with.
a.laneBeating = true
a.laneDone = make(chan struct{})
sheet := lanes.Default().Sheet()
guard.Go("session/lane-beat", func() {
defer close(a.laneDone)
lanes.Beat(ctx, sheet, models, 0)
})
}
// laneBeatModels is the models this session actually sends to, deduplicated and
// without the empties.
//
// IT IS THE SLOTS AND NOT THE CATALOG. The conversation's own model is what
// every turn rides, and the task model is what a node rides when its proposal
// names none; both are models this session will really open a connection to, so
// both are worth a prior. The fallback list is deliberately absent: those are
// models a turn moves to only when no endpoint would take the request at all,
// and fetching a sheet for every one of them would be paying, on every session,
// for a refusal that usually never comes.
//
// IT IS THE LEDGER'S NAME FOR EACH SLOT AND NOT THE OPERATOR'S. A slot may hold
// a floating alias — the shipped default is one — and the router publishes an
// endpoints page under the concrete model it points at, never under the alias.
// Fetched under the spelling as typed, the sheet lands in a third ledger key
// beside the two the sighting side was already splitting, and the session that
// paid for it reads no prior at all. See [lanes.LedgerModel]; with no catalog
// installed it is the bare-model normalisation the beat always had.
//
// It takes the Config rather than the agent so that the one question worth
// asking of it — which models does this session mean? — can be asked without
// building a session.
func laneBeatModels(config Config) []string {
var models []string
seen := make(map[string]bool, 2)
for _, slot := range []string{config.Model, config.TaskModel} {
account := config.clientConfig(slot, 0)
if account.Direct {
// A direct service has one road. Its model belongs on no endpoints
// queue, even while another account in this process has a live beat.
continue
}
slot = lanes.LedgerModel(slot)
if slot == "" || seen[slot] {
continue
}
seen[slot] = true
models = append(models, slot)
}
return models
}
// stopLaneBeat cancels the fetch and joins this session's beat. Cancellation
// ends network work, but a cache or ledger write already in progress must finish
// before Close lets a caller remove or switch the state directory.
func (a *Agent) stopLaneBeat() {
if a.laneStop != nil {
a.laneStop()
}
if a.laneDone != nil {
<-a.laneDone
}
}
// threadID is the identity this session posts its transcript under — the
// journal header's id when there is a file, the folder's name when there is a
// folder, and the one minted for the cache lineage otherwise.
//
// The cache key cannot answer it — that is a hash, deliberately, so nothing
// about a session's id reaches a router's logs — so the id itself is held.
// No lock: this runs inside the constructor, before the agent is reachable.
func (a *Agent) threadID() string {
if id := strings.TrimSpace(a.id); id != "" {
return id
}
return a.config.Place.ID()
}
// sessionCacheKey names one session's prompt-cache lineage. It is derived
// through [provider.RunCacheKey] so the key is the same shape every other
// codeaf lineage uses — a "codeaf-" prefix an operator can recognize in a
// router's logs — and so nothing about the session's own id reaches the wire.
func sessionCacheKey(id string) string {
if strings.TrimSpace(id) == "" {
return ""
}
return provider.RunCacheKey("session/"+id, "")
}
// Usage returns the session's accumulated usage.
//
// IT IS NOT A SECOND SET OF BOOKS. Every figure in it was put there by
// [Agent.bank] — the one door a call's money goes through — and that same call
// hands the machine's ledger its row (usage_ledger.go). So this and the ledger
// are one total kept by one owner and a tail read of the file, never two
// accumulators that can drift: the status line, the day's reading and the spend
// place cannot disagree by construction (issue #269). It is a running total
// rather than a scan of the file because a surface asks this on the frame clock
// and a ledger with a year in it must not be walked per frame.
//
// The one place it is deliberately LARGER than this session's own ledger rows is
// a fold: a child agent wrote its own calls down under its own id, and folding
// its tally home moves these books and writes nothing there (the fold rule).
// [UsageTree] is how a surface reads the family back out of the ledger without
// counting the same call twice.
func (a *Agent) Usage() Usage {
a.mu.Lock()
defer a.mu.Unlock()
return a.usage
}
// ContextTokens is what the conversation now weighs, in tokens: the same figure
// the compaction threshold is checked against, under the same lock.
//
// It is THE honest number, and honest means two different things depending on
// what has happened. When a response has come back, it is the provider's own
// count of the request it just served — the system prompt, the tool schemas,
// every tool result, the arguments of every call, all the bytes a surface
// counting words cannot see. When the transcript has grown since (a 300KB file
// read that has not been sent yet), the content estimate is larger and wins. See
// [Agent.meterTokensLocked] for why it is the max of the two.
//
// Zero is a session that has neither sent nor recorded anything, which is the
// only case where "nothing" is true.
func (a *Agent) ContextTokens() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.meterTokensLocked()
}
// Model returns the model the next request will use.
func (a *Agent) Model() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.model
}
// SetModel swaps the model, and A PERSON'S WORD WINS AT THE NEXT REQUEST.
//
// A swap made while the agent is working reaches the work through steer.go's one
// door ([Agent.hearModelLocked], which states the law and the failure it was
// measured against): the request in flight is cut and asked again on the new
// model if nothing of it had reached the person, and otherwise the answer they
// are reading finishes and the next request the work makes carries the new
// model. It is never the next TURN — a task step is one turn and can run for
// twenty minutes.
//
// Between two requests the latched value rides every step and retry, so a swap
// arriving mid-stream cannot send one model the transcript another model was
// half-way through writing (loop.go).
//
// THE TURN ITSELF MAY STILL MOVE, and this is the one thing that moves it. A
// step whose stream is cut over and over spends a budget and then hops to the
// next model in the chain, announced, and the rest of that turn finishes there
// (loop.go's completeWithRetry). It is a rescue and not a preference: what a
// person set here is untouched, so the NEXT turn starts on the model they
// picked, and the only way this field changes is somebody calling this.
//
// AND IT IS WHERE THE PICTURES ARE MADE SAFE. A conversation carrying attached
// images carries them as base64 in the live transcript, re-sent on every step
// of every turn after they arrived; swapping onto a model that cannot see would
// send them to it with no gate in the way, because no image is being attached
// this turn. [Agent.scrubBlindImagePartsLocked] states the whole rule and its
// three deliberate limits — the journal is untouched, the swap is one-way, and a
// model that CAN see is handed everything unchanged.
func (a *Agent) SetModel(model string) { a.setModel(model) }
// setModel is SetModel with the answer to "when does this land", which the doors
// inside this package that have somebody to tell need ([Agent.RetargetTask]) and
// the exported one has nowhere to put. An empty model is no pick at all and
// lands nothing.
func (a *Agent) setModel(model string) ModelLanding {
model = strings.TrimSpace(model)
if model == "" {
return ModelLandsNextRequest
}
a.mu.Lock()
// A PICK THAT CHANGES NOTHING IS NOT A WORD. Re-choosing the model the work is
// already talking to is a person confirming, not redirecting, and letting it
// cut would spend their money reaching the same machine again for the same
// answer.
//
// AND THE COMPARISON IS AGAINST THE MODEL THE WORK IS ON, never against the
// session's own. The two differ exactly when a step has been rescued onto a
// fallback, and that is the case where getting it wrong costs something both
// ways: a person picking the SESSION's model while the step rides a fallback
// is redirecting — real news the old reading called none — and a person
// picking the FALLBACK the step already rides is confirming, which the old
// reading called a change and paid a whole cut request for, under a room line
// that said `switching now` and a step that changed nothing (steer.go's
// [Agent.ridingNowLocked]).
changed := a.ridingNowLocked() != model
a.model = model
if a.clientPool != nil {
a.clientPool.setSeat(model)
}
landing := ModelLandsNextRequest
if changed {
landing = a.hearModelLocked(model)
}
if !a.running {
a.rebindClientLocked(model)
}
if a.config.ContextWindowFor != nil {
if window := a.config.ContextWindowFor(model); window > 0 {
a.contextWindow.Store(int64(window))
}
}
// AND WHAT IS KNOWN ABOUT THE NEW MODEL'S REAL WINDOW, which is a different
// fact from the card's figure above and travels with the model rather than
// with the session (loop.go's [Agent.noteModelWindow]).
a.noteModelWindow(model)
a.scrubBlindImagePartsLocked(model)
a.followModelOnThePageLocked()
a.mu.Unlock()
// AND THE BEAT IS TOLD, OUTSIDE THE LOCK. Everything above is about this
// session's own state; this is about a fetch somebody else will do, and a
// lock held across a hand-off is a lock held for no reason.
a.noteLaneModel(model)
// Other windows read the conversation model from its saved configuration.
// A model choice exists before another turn is sent, so publish it now.
a.stampModel()
return landing
}
// SetSources replaces the live service set used by this conversation and by
// every child it opens later. If the current model's account moved or was
// removed, the retained provider client is replaced before another request can
// use the old address or bearer. A running turn keeps the client it started on;
// startTurnLocked performs the pending replacement for the next turn.
func (a *Agent) SetSources(sources modelsource.Set) {
if a == nil || sources.Empty() {
return
}
a.mu.Lock()
a.config.Sources = sources
a.clientPool.setSources(sources)
if !a.running {
a.rebindClientLocked(a.model)
}
a.mu.Unlock()
}
// noteLaneModel tells the sheet beat about a model this session has moved to.
//
// IT IS THE SECOND HALF OF THE REPORTED DEFECT. The beat's model list is
// settled when a session opens, from the two config slots ([laneBeatModels]),
// and [Agent.SetModel] is what the picker calls — so until this line a model a
// person chose deliberately never got a sheet for the rest of the session's
// life, and cold start was the STEADY STATE for exactly the models people care
// most about. Every prior the routing design rests on was absent for them.
//
// NOTHING WAITS FOR IT. [lanes.WantSheet] queues the name and returns; the beat
// fetches it on the other side of the channel and joins it to its round, so the
// second turn on a picked model is warm and the first is no slower for it. A
// session that runs no beat — routing off, a base that is not a router, no model
// slot filled ([Agent.startLaneBeat] states all three) — has nobody to tell, and
// says nothing rather than starting a fetch nobody asked for.
func (a *Agent) noteLaneModel(model string) {
if a == nil || !a.laneBeating {
return
}
a.mu.Lock()
configured := a.config.clientConfig(model, 0)
a.mu.Unlock()
if configured.Direct {
return
}
lanes.WantSheet(model)
}
// SetAPIKey hands the conversation the key it talks with, after the fact.
//
// It exists for one moment: the surface opened on a profile with no key, asked
// for one on its first screen (internal/tui3's firstrun.go), and the person
// pasted it — into a session that already exists, holding a client built
// without one (internal/provider's NewClient states that a keyless client
// refuses every request until this lands). The config copy is updated too,
// because every worker this agent spawns — a task node, an audit, a standing
// firing — is built from `a.config` and would otherwise inherit the empty key
// the boot had.
//
// A client that cannot take a key — a test double — is left alone rather than
// refused: the config still records the key, which is what the doubles read.
func (a *Agent) SetAPIKey(key string) error {
key = strings.TrimSpace(key)
a.mu.Lock()
defer a.mu.Unlock()
// Every real session wraps its provider client in [sessionCompleter] before
// the surface can hand a key over. Asking the wrapper itself whether it can
// take a key quietly answered no, updated only the config copy below, and
// left the first model request on the empty bearer it opened with. Reach the
// same underlying client task children use, then update it before recording
// the key for workers spawned later.
if err := a.setDefaultClientKey(key); err != nil {
return err
}
a.config.APIKey = key
a.config.Sources = a.config.Sources.WithDefaultKey(key)
a.clientAccount = accountFor(a.config, a.model)
return nil
}
// ── reasoning strength ──────────────────────────────────────────────────────
//
// How hard the model is asked to think is a CHOICE ABOUT A MODEL, not about a
// session, so it is kept per model id and not as one field. A person who dials
// a reasoning model to high, switches to a cheap one for a quick question and
// switches back finds the high still there: the second model never had a level,
// and setting one on it would have been a decision nobody made.
//
// The levels are the effort ladder's rungs (internal/effort). "off" here means
// SEND NOTHING — no reasoning field on the wire, the model's own default — and
// NOT provider.EffortOff, which sends {"reasoning":{"enabled":false}} to
// suppress the thinking pass outright. The distinction matters at exactly this
// seam: a person turning a knob back to off is saying "stop asking for extra
// thinking", which is the model's default, while EffortOff is a harness economy
// that some endpoints refuse with a 400 (provider's quirks.go). The harness may
// spend a round-trip discovering that; a person changing their mind must not.
//
// OFF HERE IS NOT SILENCE ON THE LADDER. It clears this scope, and the rung
// below — the conversation, the work, the install's default — is what the next
// turn then asks for (effort.go). A person who wants nothing asked for at all
// turns the ladder itself off, which is a choice they make once.
// Reasoning is the level the model now in use will be asked for, "" when none
// is set.
func (a *Agent) Reasoning() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.reasoningLocked(a.model).String()
}
// ReasoningFor is the level held for one model id, whichever model is in use.
// It is what a picker asks while drawing a row for a model nobody has switched
// to yet.
func (a *Agent) ReasoningFor(model string) string {
a.mu.Lock()
defer a.mu.Unlock()
return a.reasoningLocked(model).String()
}
// SetReasoning sets the level for the model now in use, for subsequent turns.
// A turn in flight keeps the level it started with: runTurn latches it once
// (loop.go), so a change made while the agent is working lands at the next
// Submit.
//
// AND THAT IS NO LONGER THE MODEL'S RULE. A model a person names reaches the
// work at the next REQUEST, cutting the one in flight when it has produced
// nothing they could use ([Agent.SetModel], steer.go's THE PERSON'S WORD WINS).
// The two rules differ because the two acts do: a person changing models is
// redirecting work they are watching go the wrong way, and a person turning the
// thinking up is setting a level for the next thing they ask.
//
// WHAT MOVES THE MODEL STILL MOVES THIS. Whenever the step's model changes — a
// rescue's hop, or the person's own word — the level is re-read for the model
// now in hand, because a level is a choice about a model and carrying one across
// would be asking the new model for something nobody set on it.
//
// An unrecognized level is ignored rather than cleared. The two callers are a
// picker that can only produce the four it draws and a flag the door has
// already validated with [ParseReasoning]; between them, a value this does not
// know is a bug upstream, and answering it by silently dropping a level the
// person did set would hide it.
func (a *Agent) SetReasoning(level string) {
a.mu.Lock()
defer a.mu.Unlock()
a.setReasoningLocked(a.model, level)
}
// SetReasoningFor sets the level for one model id without switching to it.
func (a *Agent) SetReasoningFor(model, level string) {
a.mu.Lock()
defer a.mu.Unlock()
a.setReasoningLocked(model, level)
}
func (a *Agent) setReasoningLocked(model, level string) {
key := reasoningKey(model)
if key == "" {
return
}
rung, ok := parseReasoning(level)
if !ok {
return
}
// Absence is stored as absence: a map that held empty entries would answer
// "this model has a level" for every model anybody ever cycled back to off.
if rung == effort.None {
delete(a.reasoning, key)
return
}
if a.reasoning == nil {
a.reasoning = make(map[string]effort.Rung, 2)
}
a.reasoning[key] = rung
}
func (a *Agent) reasoningLocked(model string) effort.Rung {
key := reasoningKey(model)
if key == "" {
return effort.None
}
return a.reasoning[key]
}
// reasoningKey folds a model id the way every other lookup on this surface
// does — trimmed and case-insensitive — so a level set from a picker row is
// found again by a /model <slug> typed in another case.
func reasoningKey(model string) string {
return strings.ToLower(strings.TrimSpace(model))
}
// ParseReasoning normalizes one operator-supplied level and reports whether it
// is one. It answers in the surface's own words so a door can validate
// `--reasoning` without importing the adapter, and "off" and "" both normalize
// to "" — see the block comment above for why off is silence and not
// provider.EffortOff.
//
// It is [effort.Parse] under a name the doors already call, so the five rungs a
// person may type here are the same five the ladder holds and there is no
// second list to keep true.
func ParseReasoning(level string) (string, bool) {
rung, ok := parseReasoning(level)
return rung.String(), ok
}
func parseReasoning(level string) (effort.Rung, bool) { return effort.Parse(level) }
// SetContextWindow tells the agent how many tokens the model it is now running
// actually accepts, and the compaction threshold follows it from the next check
// onward.
//
// It exists because [Agent.SetModel] does. Config.ContextWindow is the window of
// the model the session STARTED on; a person who switches to a 1M-token model
// mid-conversation would otherwise keep compacting at the old model's threshold
// — reading 128k of a window eight times that size — and one who switches the
// other way would overflow. A surface that knows the new model's window (the
// catalog's figure) sets it here alongside SetModel; one that does not, does
// not call this, and the configured window stands.
//
// Zero and negative are ignored rather than clearing the window: "I don't know
// this model's size" must not be spelled the same way as "this model has no
// context", and a caller passing an unknown figure through means the first.
func (a *Agent) SetContextWindow(tokens int) {
if tokens <= 0 {
return
}
a.contextWindow.Store(int64(tokens))
}
// Submit appends text as a user message and runs one turn: provider requests
// interleaved with tool execution until the assistant answers without a tool
// call. The returned channel streams the turn's events and is closed after
// EventTurnDone or EventError. Submitting while a turn runs injects the
// message into that turn (steering) rather than starting a second one.
//
// EVERY call returns a live channel, steering included. The in-flight turn
// owns an event hub, and a steering Submit subscribes a fresh channel to it:
// the caller sees the turn's events from its own subscribe point onward and
// the close at EventTurnDone. There is no replay — a surface that submits per
// message is watching the turn from where it spoke, not re-reading it — and a
// surface holding both channels sees each event on each, which is what a
// per-message caller wants and what a whole-session reader must de-duplicate.
func (a *Agent) Submit(ctx context.Context, text string) (<-chan Event, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, errors.New("session: empty message")
}
// A @ team or chat in the words is a reference, and the model needs a
// bounded digest of it (mention.go). The journal keeps the words as typed.
said := text
if note := a.mentionNote(text); note != "" {
text = text + "\n\n" + note
}
// A PERSON'S TURN OPENS ON WHAT IS RUNNING, while anything is (plandigest.go
// states why it is pushed rather than asked for). The digest is read here,
// on the person's own door, and nowhere else: a wake note, a job's ending
// and a steer are not sentences that can change what a task should do, and
// the block is the empty string whenever no run is live, which is every turn