Repository navigation
Expand file tree
/
Copy pathagent.ts
More file actions
1123 lines (1056 loc) · 53.1 KB
/
Copy pathagent.ts
File metadata and controls
1123 lines (1056 loc) · 53.1 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
/**
* JevLoop · agent loop
*
* ══════════════════════════════════════════════════════════════
* 这个 loop 里,只有最后那个 generate() 是一次大模型调用。
* 其余每一个 ↗ 都是判定模型,10ms 量级,近乎免费。
* ══════════════════════════════════════════════════════════════
*
* step ─┬─ loop.needsTool ↗ 需要动手吗? ──否──▶ 直接生成
* │
* ├─ loop.pickTool ↗ 用哪个工具?(选项每步重建)
* │
* ├─ loop.gradeRisk ↗ 这个操作多危险? ──▶ 需要授权就问人
* │
* ├─ [ 工具执行 ] ← 唯一有真实副作用的地方
* │
* ├─ loop.stepOk ↗ 成功了吗?
* │
* └─ loop.isDone ↗ 做完了吗? ──否──▶ 下一个 step
* │
* ▼
* [ 模型生成 ] ← 整个 loop 里唯一贵的一步
* │
* loop.canDeliver ↗ 能交付吗?
*
* ## 待拆
*
* 两件事:**loop 本身**(`runAgent`)与**给工具定输入**(`resolveInput`)。
* 接缝清楚 —— `resolveInput` 只依赖 `frame.ts` 的候选与 `pickInput` 判定,
* 不认识 loop 的状态机。**接缝已经定了,还没切。**
* **行数不在这里写** —— 它会漂,而且这行注释本身就在改变它(见 docs/CODE-STYLE.md §12)。
*
* @module JevLoop/agent
*/
import { Decider } from './decide.ts'
import type { DecisionResult, DecisionSpec } from './vocab-decision.ts'
import type { AnswerMap, QuestionSet } from './vocab.ts'
import { Meter } from './meter.ts'
import { clip } from './budget.ts'
import { buildDecisions, GENERATOR_INSTRUCTION, type DecisionSet } from './decisions.ts'
import type { GateOverrides } from './gates.ts'
import type { ResumeState } from './resume.ts'
import { hasFileOptions, type AgentCtx, type StepRecord } from './frame.ts'
import { callTool, isToolName } from './act.ts'
import { LOCAL_TOOLS, type ToolName, type ToolTable } from './act-local.ts'
import { assertNever } from './util.ts'
import {
foldEvidence,
priceGenerateRequest,
EVIDENCE_POLICY,
type ContextReport,
type RequestEstimate,
} from './context.ts'
import {
decisionEvent,
type AgentObserver,
type BudgetLine,
type GenDelta,
type RunDelta,
type RunBudget,
} from './events.ts'
export type { AgentEvent, AgentObserver, GenerateDelta } from './events.ts'
import type { Generator, ConversationTurn } from './llm.ts'
import { foldConversation, type ConversationReport } from './conversation.ts'
import { writeInputVia } from './write-content.ts'
export interface AgentOptions {
task: string
cwd: string
decider: Decider
generator: Generator
maxSteps?: number
/** 判定节点要求显式授权时调用。**默认拒绝** —— 宁可不动,也不擅自做不可逆操作 */
onAskHuman?: (reason: string, tool: string) => Promise<boolean>
onTrace?: (line: string) => void
/**
* `write_file` 的**输入来源**(`路径\n内容`)。**不提供时 `write_file`
* 根本不会进候选**,于是判定模型没有机会去选一个 loop 兑现不了的动作。
*
* 路径和内容都是**生成**,按三分法不属于判定模型(`docs/CODE-STYLE.md`
* §8.1)。以前这里只提供「内容」,路径由 `pickInput` 从**已存在的文件**
* 里挑 —— 于是「写进一个**新建的** SUMMARY.md」这种任务没有任何地方能
* 产生那个名字(见 `write-content.ts` 的文件头)。
*
* 返回 `undefined` 表示这次写不了 → loop 停机,而不是写个占位符交差。
* (以前没有这个钩子时,`resolveInput` 返回的是占位符
* `(内容由调用方提供)`,而它**真的会被写到盘上**:实测一次运行把目标
* 文件的全部内容替换成了那句话,还连写了 5 次,最后 `halt: max_steps`。)
*
* ⚠️ **不传 = 用生成器现造**(缺省,见下面那段)。**传 `null` = 明确不要
* 写入能力** —— 那道门会关上,`write_file` 不进候选。这两种情况的区别
* 是有意的:不传是「我没想过这件事」,`null` 是「我知道,我不要」。
*/
provideWriteInput?: null | ((ctx: AgentCtx) => string | undefined | Promise<string | undefined>)
/**
* 是否允许 `delete_file` 进候选。**默认 false。**
*
* ★ 刻意**不**复用 `provideWriteInput`/`canWrite`:写入和删除不是同一个
* 信任级别,复用等于替已经开了写入的调用方凭空放宽边界。
*
* 打开之后,`delete_file` 的目标路径仍然由 `provideWriteInput` 给
* (「删哪一个」不是从候选里挑的 —— 见 `resolveInput`),而且它必须过
* `grade_risk` 的 `score:risk >= 2 → ask_human` 硬闸门:没有 `onAskHuman`
* 时**默认拒绝**,于是 loop 停在授权那一步,而不是把文件删掉。
*/
allowDelete?: boolean
/**
* **这一次运行用哪张工具表。** 默认 `LOCAL_TOOLS`(本地文件系统)。
*
* 缝的另一半(TODO §1):工具的实现可以整体换掉 —— 沙箱、远程 FS、一个
* 纯字符串的测试桩 —— 而内核一行都不用改。
*
* ★ **名字不能换,只有实现能换。** 内核只对这六个名字有输入解析规则
* (`resolveInput` 的穷尽 switch);换一个名字,它会**响亮地拒绝**
* (`assertNever` 抛「未处理的联合成员」),而不是猜一个输入 ——
* 猜出来的输入会被真的执行。类型 `ToolTable` 要求六个名字齐全,
* 所以「注入一半」是编译错误。
*
* ★ 它不只决定谁来执行:`grade_risk` 的 `base_risk` 是**工具自己声明的**,
* 所以风险闸门的输入也来自这张表。两处用不同的表,闸门就会拿一张表的风险档
* 去判另一张表的调用 —— 这是把 `tools` 一路传到 `buildDecisions` 的原因。
*/
tools?: ToolTable
/**
* **这次运行确认在隔离环境里跑**(容器 / cgroup / 网络命名空间)。
*
* ★ 默认 `false`。它是「谁为 CPU / 内存 / 磁盘 / 网络负责」这个问题的答案,
* 而猜错的方向是把没有界的运行当成有界的 —— 所以它必须显式给。
*
* 唯一会看它的是**声明了 `requiresIsolation` 的工具**(`Tool.requiresIsolation`):
* 没有确认时 `callTool` 连跑都不跑。应用层从 `JEVLOOP_ISOLATED=1` 读它 ——
* 那个变量正是容器配方里设的(见 `SECURITY.md`)。
*/
assumeIsolated?: boolean
/**
* **从最后一个可恢复的步骤接着跑**(TODO §10)。由 `resumePointFrom()` 从
* 会话日志折出来,见 `src/resume.ts`。
*
* ★ 给了它之后,`task` / `cwd` 以它为准;两处**对不上就抛**,而不是悄悄挑一个
* —— 静默挑一个意味着「跑的是哪个任务」这件事没人说得清。
*/
resume?: ResumeState
/**
* 一次运行的**硬上限**(TODO §9)。三个都可以单独给,越线就**停机**。
*
* ★ 单位是墙钟毫秒 / 模型调用数 / token —— **不是钱**。仓库里没有价格表,
* 要报美元就得编一张,而那是「没人能复现的数字」(见 CONTRIBUTING)。
*
* ⚠️ **保证的是「越线之后不会再开新的一步」,不是「绝不超过」。**
* 检查在每步开始前做,而越线往往正是那一步的请求造成的 —— 一次请求
* 发出去就收不回来。它是刹车,不是预算封顶。撞上之后 `halt` 里会写清
* 是哪一条、上限多少(`budget_wall:3000ms` 这种),不会静默停住。
*
* 不给 = 不设上限(默认),行为和以前一样。
*/
maxWallMs?: number
maxModelCalls?: number
maxTokens?: number
/**
* 之前的轮次。**多轮会话的入口** —— 没有它,每一句都是孤立的任务,
* 「再读一遍那个文件」里的"那个"无处可指。
*
* 只有问答、没有中间过程(见 `llm.ts` 的 `ConversationTurn`)。
* 调用方负责给出**有界**的份数。
*/
history?: readonly ConversationTurn[]
/**
* 观察者。每次判定、每次工具调用、每次生成都会发一个事件。
*
* 和 `onTrace` 的分工:`onTrace` 是给人读的一行字,`onEvent` 是**结构化的**,
* 给界面、测试、日志消费。两者可以同时用。
*/
onEvent?: AgentObserver
/**
* 生成过程中的增量文本。
*
* ★ **这是和 `onEvent` 平行的一条通道,不是它的一个新事件类型。**
* 理由见 `events.ts` 的 `GenerateDelta`:一次回答有上千段,而轨迹和日志
* 记的是决定(判定了几次、选了哪个工具)—— 混在一起,那 13 行会被
* 1000 行淹掉,而「判定 13 次、模型 1 次」正是这个项目要给人看的东西。
*
* 分开还买到一个结构上的保证:**日志不可能收到增量**,因为写日志的那条
* 路上根本没有它。靠 `if (e.type === …) return` 过滤是会被忘掉的。
*
* 步号由这里注入(生成器不知道自己跑在第几步,那是 loop 的事)。
* 不传 = 不流式,行为和不加这个选项时一样。
*/
onDelta?: (d: RunDelta) => void
/**
* **门限覆盖**:`<块 id>.<问题 id>` → 新门限,例如
* `{ 'can_deliver.unsupported': 0.7 }`。
*
* 不传 = 全用 `DECISION.md` 里的默认值。名字写错会**当场抛**,
* 而且覆盖用的那个数会一路带进 `reason`(日志里写的是真正用过的值)
* 和 `run:start`(这一轮用了哪些覆盖)—— 见 `gates.ts` 文件头。
*/
gates?: GateOverrides
}
/**
* 「问一次判定」的形状。
*
* 抽出来是为了让 `resolveInput` 也拿到**同一个**入口 —— 它原本收的是
* `decider` 和 `record` 两个参数,于是「播报 phase」这件事在那个函数里
* 根本做不到(它够不到 `runAgent` 的闭包)。只传一个 `ask` 之后,
* 「问判定」只有一种写法,漏掉播报是不可能的。
*/
type Ask = <C, Q extends QuestionSet>(
spec: DecisionSpec<C, Q>,
ctx: C,
) => Promise<DecisionResult<AnswerMap<Q>>>
export interface AgentResult {
answer: string
/** 停机原因 */
halt: string
steps: number
ctx: AgentCtx
meter: Meter
/**
* 工具证据那块预算的账目。**总是有** —— 没超触发线时是一份
* 「什么都没做」的账(`acted: false`),那也是一条信息:
* 它告诉你**离触发线还有多远**。
*
* ★ 以前这里写的是「可能没有」,而代码从来都给它赋值(`evidence()`
* 无条件被调用)—— 文档和代码对不上。现在按代码的事实写,并且让
* 两份账目**统一**:`context` 和 `conversation` 都总是有。
* 界面要能回答「离触发线还剩多少」,而那要求没动手时也有账。
*/
context: ContextReport
/**
* 上文那块预算的账目(单位是**轮**,不是步)。同样**总是有**。
*
* 和 `context` 各报各的:两块是独立预算,合成一个数就说不清是哪个超了。
*/
conversation: ConversationReport
/**
* 这次生成请求的 token 构成 —— 证据 / 上文 / 本句各占多少。
*
* ★ **它一直算着,只是以前算完就扔了。** `priceGenerateRequest`
* 每次都返回四个数,而这里只用了合计(`controlTokens`)去填
* `generate` 事件的 `estimatedInputTokens`,另外三个从未离开这个文件。
* 后果是界面上看得见「这次花了 5440 token(估)」,看不见
* 「这 5440 是什么构成的」—— 而后者才是能拿来调预算的那一半。
*/
request: RequestEstimate
}
/**
* 跑一次 agent。**整个 loop 里只有最后一次 `generate()` 是大模型调用**,
* 其余每一步都是判定(见模块头部的图)。
*
* 停机原因在返回值的 `halt` 里,而且它是**诚实的**:`max_steps` 就是撞上了
* 迭代上限、`input_unclear` 就是选不出输入、`denied` 就是授权被拒 ——
* 不要把这些折叠成一句「失败了」,因为排查方向完全不同。
*
* ── 会不会抛:分两半,别只记前半句 ──────────────────────────
*
* **判定后端挂掉不会抛**:会记 `degraded` 并走到 `escalate`(见 `Decider.decide`)。
*
* ⚠️ **但生成后端挂掉会抛。** 两处 `generator.generate()` 没有 try,
* 异常原样穿出去。这不是漏了 —— 判定那条线每一步都有兜底(不确定就别猜),
* 而生成这条线**没有可用的兜底**:它只有一次调用,兜底等于回答
* 「用一个不存在的回答」这个没有答案的问题。
*
* 代价落在调用方身上:**必须自己接住**。实测(2026-09-21,写标定台时踩的):
* 一次 `UND_ERR_CONNECT_TIMEOUT` 直接穿出 `runAgent`,把整轮 21 次测量
* 带走了。服务端那条路径本来就接住了(`server.ts` 的 catch 合成一条
* `halt: 'error'` 的 `run:end`,并把真实原因写进 answer)。
*
* 改动前这里只写了「不会抛:判定后端…」—— 理由只覆盖一半,结论却写成了
* 全称。照着这句话写调用方的人不会去接,然后就会撞上。
*/
export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
const { decider, generator } = opts
// Decider 的 meter 是必填的(见 decide.ts 的说明)—— 这里不再自建。
// 自建会导致:判定记进 decider 的那个,返回给调用方的是另一个空的。
const meter = decider.meter
const maxSteps = opts.maxSteps ?? 12
const trace = opts.onTrace ?? (() => {})
const emit: AgentObserver = opts.onEvent ?? (() => {})
/**
* 七个判定节点,**带着这次运行的门限**。
*
* 在 `runAgent` 开头建一次,之后整个 loop 用同一组 —— 判定节点是纯数据
* (问题 + 策略),构建很便宜,但**一次运行必须是同一份**:中途换门限会
* 让日志里的「为什么这么判」前后对不上。
*/
/*
★ **这一次运行用哪张工具表。** 和 `specs` 并排、也建一次,理由一样:
一次运行必须是同一份,而且它不只决定「谁来执行」——`base_risk` 是工具自己
声明的,所以**风险闸门的输入也来自这里**。两处用不同的表,闸门就会拿一张
表的风险档去判另一张表的调用。
默认 `LOCAL_TOOLS`:不传的调用方行为一字不变。传了就把整个内核指过去 ——
「换一个提供者」不再需要改这个文件。
*/
const tools: ToolTable = opts.tools ?? LOCAL_TOOLS
const specs = buildDecisions(opts.gates ?? {}, tools)
/**
* 把**步号**注进增量再交出去。
*
* 生成器只产出 `{ text, reset }` —— 它不知道自己跑在第几步,也不该知道
* (那是 loop 的事,见 `events.ts` 的 `GenDelta`)。所以拼接发生在这里,
* 而且**只在这里**:两处 `generate()` 调用都用它,接错一步是不可能的。
*/
const onDelta = opts.onDelta
const deltaFor = (s: number): ((d: GenDelta) => void) | undefined =>
onDelta ? (d) => onDelta({ ...d, step: s }) : undefined
// 上文压成**一句话**进 ctx —— 判定帧是**有界**的(§8.2),把整段对话
// 塞进去会把真正要看的东西挤掉。只留每一轮「问过什么」,因为判定需要的是
// **指代关系**("再读一遍那个文件"里的"那个"),不是上一轮的完整过程。
//
// ★ **倒序拼接**。`clip` 保留的是**头部**(见 `budget.ts` 的 `slice(0, …)`),
// 所以拼接方向决定了有限预算留给哪一端。这里要和 `context.ts` 的
// `fitEvidence` 对齐 —— 它也**从最近往回取**,理由同样是「最新的最相关」:
// 与当前这一步最相关的是**紧邻的上一轮**(用户刚改了什么要求、刚澄清了什么),
// 不是第 1 轮。
//
// 实测(8 轮、拼接 221 字符、预算 `EARLIER_MAX_CHARS = 200`):
// 正序 → 保留第 1–7 轮,**第 8 轮(最近的)被截掉**
// 倒序 → 保留第 8–2 轮,第 1 轮被截掉 ← 这才是想要的
//
// **别看它"顺序不对"就顺手正过来** —— 这个方向是故意的;
// 正过来就退回成「模型看得见第 1 轮、看不见用户在最后一轮改的口径」。
//
// ★ 上文的折叠**在这里算一次**,和下面的证据同一条规矩:`opts.history`
// 在一次运行里不会变,算第二遍只会重复发同一个事件。
// 它必须算在 `earlier` **之前** —— 判定帧和生成请求要看**同一份**上文,
// 否则「模型看得见第 7 轮、判定看不见」这种错位会静默地影响选工具。
const folded = foldConversation(opts.history ?? [])
// ★ **缺省就是缺省**:没有上文时传 `undefined`,不传空数组。
//
// 两者对现在的两个生成器没差别(都写 `req.history ?? []`),但 `[]`
// 在 JS 里是**真值** —— 一个写 `if (req.history)` 的生成器会走进
// 「有上文」的分支去读一个空列表。而且空数组只可能出现在「本来就没有
// 上文」这一种情况(留尾至少一轮),所以它并不比 `undefined` 多带信息。
// 摘要同理:没折过就没有摘要,而不是「有一段空摘要」。
const genHistory = folded.recent.length > 0 ? folded.recent : undefined
const genDigest = folded.digest.length > 0 ? folded.digest : undefined
/*
★ **输入来源有缺省值:用生成器现造。**
在这之前它是**可选的**,而 `src/server.ts`、`src/cli.ts`、`examples/`
**一个都没传** —— 于是 `write_file` 在每一次网页 / CLI 运行里都不进候选
(见 `frame.ts` 那道门),**通过这个服务永远写不出文件**,而唯一的信号
是一个看起来像「判定不确定」的停机。
实测(2026-09-21):任务「把两个文件里的函数写进新建的 SUMMARY.md」,
21 次判定、读完两个源文件之后,第 18 步 `pickTool` 的候选里**只有**
`read_file` 和 `done` —— 判定模型在两个错误选项里选了较不坏的那个
(0.58,低于门限)然后停机。**它的行为是对的,缺的是那个选项。**
所以缺省值补在这里,而不是让每个调用方各接一次:这个钩子对「能用的
agent」不是可选项,把它当可选就是那个窟窿的成因(N 个调用方、0 个接)。
`opts.provideWriteInput` 仍然可以覆盖 —— 想用别的来源(模板、固定
文件、从别处取)的调用方照样能换。
*/
// ⚠️ **不能用 `??`** —— `null ?? x` 会走缺省,于是「明确不要写入」
// 就没有任何表达方式了。`undefined`(没传)= 用缺省;`null` = 关掉。
const writeInput =
opts.provideWriteInput === undefined
? writeInputVia(opts.generator, GENERATOR_INSTRUCTION, (g) => {
// 和主回答那两处同一个形状(`kind` 里写明是哪一种生成),
// 所以界面、账本、计数都不用为它加特例
emit({
type: 'generate',
step,
kind: `generate/write (${opts.generator.name})`,
latencyMs: g.result.latencyMs,
inputTokens: g.result.inputTokens,
outputTokens: g.result.outputTokens,
estimatedInputTokens: g.estimatedInputTokens,
})
})
: opts.provideWriteInput
const earlier = folded.recent.slice().reverse().map((t) => t.task).join(' / ')
/*
★ 恢复:`task` / `cwd` / `files` / `readFiles` / `history` 全部来自恢复点,
**步号也接着数**。对不上就抛 —— 见 `AgentOptions.resume`。
*/
if (opts.resume && (opts.resume.task !== opts.task || opts.resume.cwd !== opts.cwd)) {
throw new Error(
`恢复点与本次调用对不上:恢复点是 ${JSON.stringify(opts.resume.task)} @ ${opts.resume.cwd},` +
`这次说的是 ${JSON.stringify(opts.task)} @ ${opts.cwd} —— 不猜,停在这里`,
)
}
const ctx: AgentCtx = {
task: opts.resume?.task ?? opts.task,
cwd: opts.resume?.cwd ?? opts.cwd,
earlier,
files: opts.resume?.files ?? [],
...(opts.resume?.readFiles ? { readFiles: opts.resume.readFiles } : {}),
history: opts.resume?.history ?? [],
// `null` = 调用方明确不要写入 → 门关上,`write_file` 不进候选(见 `frame.ts`)
canWrite: typeof writeInput === 'function',
// 删除**不跟着写入走**:默认 false,所以 `delete_file` 对现有调用方不存在。
// 要看它进候选,调用方必须显式传 `allowDelete`(见 `AgentOptions`)
canDelete: opts.allowDelete === true,
}
let step = opts.resume?.step ?? 0
let halt = 'max_steps'
/*
★ 死在半路那一步的副作用是**未知的**,而它会被重跑。说出来 ——
静默重跑一个可能已经写过的操作,是这个文件最不该犯的那种错。
*/
if (opts.resume?.inFlight) {
const f = opts.resume.inFlight
trace(
`resuming at step ${step} — step ${f.step} (${f.tool}) has no result, so whether its effect happened is unknown; it will run again`,
)
}
/*
── 每轮硬上限(TODO §9)────────────────────────────────────
§9 的原话是「唯一的上限是 maxSteps,跑一轮花多少钱、多少时间都没有盖」。
这里补上三个,**都是停机**,不是事后警告。
★ 为什么是「墙钟 + 模型调用数 + token」,而不是「多少钱」:
`context.ts` 的 `priceGenerateRequest` 定价的单位是 **token**,仓库里
**没有**价格表 —— 也没有 provider 的单价。要报「美元」就得编一张表,
而 CONTRIBUTING 明确把「没人能复现的数字」列为会直接关掉的那一类。
所以上限只用真的量得到的单位。
★ **它保证什么、不保证什么**(照实说,否则就是个安静的谎):
检查发生在**每一步开始之前**,所以保证的是「越线之后不会再开新的一步」,
**不是**「总花费绝不超过这个数」—— 一次请求发出去就收不回来,
而越线往往正是那一次请求造成的。上限因此是个**刹车**,不是**预算封顶**。
*/
const startedAt = Date.now()
const spent = (): string | null => {
if (opts.maxWallMs !== undefined && Date.now() - startedAt > opts.maxWallMs) {
return `budget_wall:${opts.maxWallMs}ms`
}
const s = meter.stats
if (opts.maxModelCalls !== undefined && s.modelCalls >= opts.maxModelCalls) {
return `budget_model_calls:${opts.maxModelCalls}`
}
// ★ `s.inputTokens` / `s.outputTokens` 是**生成 + 判定**的总数(TODO §9):
// 判定占大头的运行(托管 Jev)以前只被墙钟和调用次数框住,现在被它
// 真正花的 token 框住。后端没报 usage 的批次按 0 计,所以它是下界 ——
// `s.decisionBatchesWithoutUsage` 把这个下界说在明处。
if (opts.maxTokens !== undefined && s.inputTokens + s.outputTokens >= opts.maxTokens) {
return `budget_tokens:${opts.maxTokens}`
}
return null
}
// 门限覆盖**进这一轮的第一条事件** —— 它是持久化的,所以事后翻日志能
// 看出「这一轮跑在什么门限上」。没有它,一次跑在 0.7 上的运行和一个跑在
// 默认值上的运行在日志里长得一模一样(§8.10)。
const gates = opts.gates && Object.keys(opts.gates).length > 0 ? opts.gates : undefined
emit({ type: 'run:start', task: opts.task, cwd: opts.cwd, at: Date.now(), ...(gates ? { gates } : {}) })
// 上文被折过就说出来。它发生在 loop 之前,所以步号是 0。
// **只有真的动了才发** —— 没超触发线时什么都不做,那没什么可报的。
if (folded.report.acted) {
emit({
type: 'conversation',
step: 0,
rawChars: folded.report.rawChars,
keptChars: folded.report.keptChars,
rawTurns: folded.report.rawTurns,
keptTurns: folded.report.keptTurns,
foldedTurns: folded.report.foldedTurns,
overRetain: folded.report.overRetain,
})
}
/**
* 记一笔判定并发事件。
*
* loop 里每个 `decider.decide` 的返回值都过这个函数 —— 漏一处,
* 界面上就少一个决策点,而那种缺失不会报错,只会静默地少一块。
*/
const record = <A>(d: DecisionResult<A>): DecisionResult<A> => {
emit(decisionEvent(d as DecisionResult<unknown>))
return d
}
/**
* 问一次判定 —— **先播报「要问了」,再问**。
*
* ★ 两件事的顺序是重点,不是风格。`decision` 事件是**做完之后**才发的
* (它带着 `latencyMs` 和答案),所以只靠它,观察者只能拿「上一次干完
* 的是什么」去猜「现在在干什么」—— 而它在**最长的那一步上错得最久**。
*
* 实测(2026-09-21,用户报的):生成那 2 秒里界面显示「正在判定」,
* 因为最后一条事件是 `isDone`。`tool:call` 本来就在跑之前发,所以
* 工具段一直是对的;判定段和生成段缺「开始」,这里补上。
*
* 顺带把 `record` 收进来:每个判定点都必须过这一道,**漏一处界面就少
* 一个决策点**,而那种缺失不会报错(`record` 的注释也是这么写的)。
* 合成一个函数之后,「忘了发 phase」和「忘了记 decision」变成同一个
* 错误 —— 只有一种写法。
*/
const ask: Ask = async (spec, c) => {
emit({ type: 'phase', step, kind: 'decide', id: spec.id })
return record(await decider.decide(spec, c))
}
/**
* 一次请求问**多个独立的**判定。
*
* ★ **为什么要合并**:判定占墙钟 62–80%(§8.11,托管 Jev 一次约 390ms),
* 而每一步要问好几次。官方 skill 的话:「**Ask independent questions
* over the same state together** … They run in parallel and cannot see
* one another's answers.」—— 一次请求里加问题是**并行打分**的,
* 延迟不随问题数增长。
*
* ★ **为什么两个结果都要发事件**:轨迹上仍然是两个判定点。合并省的是
* **一次网络往返**,不是一次判断 —— 每个判定各跑各的策略、各自决定动作。
* `state` 报的是**实际发出去的那份**(合并后的帧),因为那是事实。
*
* 「独立」的硬要求见 `Decider.decideMany` —— 问题 id 撞了、或者状态里
* 同名键有两个值,它**抛**。
*/
const askMany = async <QA extends QuestionSet, QB extends QuestionSet>(
specs: readonly [DecisionSpec<AgentCtx, QA>, DecisionSpec<AgentCtx, QB>],
c: AgentCtx = ctx,
): Promise<[DecisionResult<AnswerMap<QA>>, DecisionResult<AnswerMap<QB>>]> => {
// phase 报**全部**节点名:它们同时开始,报一个会让人以为另一个还没开始
emit({ type: 'phase', step, kind: 'decide', id: specs.map((x) => x.id).join(' + ') })
// 转换说明:`decideMany` 收的是擦掉每节点具体类型的形状(它按问题 id 分发),
// 而这里的元组签名是为了让调用点拿回**各自的**答案类型(`pick.answers.tool.choice`)。
// 返回值再转回来 —— 两边是同一批对象,只是类型面不同。
const results = await decider.decideMany(
specs as unknown as readonly DecisionSpec<AgentCtx, QuestionSet>[],
c,
)
for (const r of results) record(r)
return results as unknown as [DecisionResult<AnswerMap<QA>>, DecisionResult<AnswerMap<QB>>]
}
// ── 工具循环 ──────────────────────────────────────────────
while (step < maxSteps) {
// 越线就不再开新的一步。放在这里(而不是每步结尾)是**为了在花钱之前停**:
// 越线之后的第一件事就是一次判定请求。
const over = spent()
if (over) {
halt = over
opts.onTrace?.(` budget reached (${over}) → stopping before step ${step + 1}`)
break
}
step++
decider.setStep(step)
/*
↗ 需要动手吗 + ↗ 用哪个工具(候选每步重建)
**这两个一起问。** `pickTool` 的候选由 `toolsFor(ctx)` 算出来,和后
者答什么无关,所以它们是独立的:一次请求拿到两份答案,代码再按
`needsTool` 的答案决定要不要用 `pickTool` 那份。
下面仍然**先看 needsTool** —— 顺序在代码里,模型看不见彼此。
*/
const [need, pick] = await askMany([specs.needsTool, specs.pickTool])
if (need.action === 'answer') {
halt = 'answered_directly'
trace(` answering directly (no tool needed)`)
break
}
if (pick.escalate || pick.action !== 'call') {
halt = 'tool_unclear'
trace(` tool choice unclear → stopping (${pick.reason})`)
break
}
const picked = pick.answers.tool.choice
// 模型返回的工具名是**不可信输入**,调用前必须过这一道(docs/CODE-STYLE.md §6 允许的真实边界)。
// 不过会怎样:`callTool` 返回「错误:没有这个工具」,而这个字符串会被当成
// 普通工具输出喂给 `stepOk` —— 判定模型分不清「工具跑出来的结果」和「工具不存在」。
if (!isToolName(tools, picked)) {
halt = 'unknown_tool'
trace(` model returned a tool that does not exist: '${picked}' → stopping (never fed to the next decision as a result)`)
break
}
const tool: ToolName = picked
if (tool === 'done') {
halt = 'agent_done'
trace(` agent ended the tool loop`)
break
}
// 先记下要调用什么,`loop.pickInput` 的候选依赖 lastTool
ctx.lastTool = tool
// 工具参数是一次**判定**,不是写死的代码(审计 N3)
const input = await resolveInput(tool, ctx, ask, writeInput, specs.pickInput)
if (input === undefined) {
halt = 'input_unclear'
trace(` could not choose an input for ${tool} → stopping`)
break
}
const pending: StepRecord = { step, tool, input, result: '' }
ctx.history = [...(ctx.history ?? []), pending]
// ↗ 这个操作多危险
const risk = await ask(specs.gradeRisk, ctx)
if (risk.escalate || risk.action === 'ask_human') {
const approved = opts.onAskHuman ? await opts.onAskHuman(risk.reason, tool) : false
emit({ type: 'authorize', step, tool, reason: risk.reason, approved })
trace(` ⚠ authorisation required: ${tool} (${risk.reason}) → ${approved ? 'approved' : 'denied'}`)
if (!approved) {
halt = 'denied'
ctx.history = ctx.history.slice(0, -1)
// ★ `lastTool` 必须跟着一起回退。它和 `history` 是**两个字段说同一件事**
// (「最后发生了什么」),而两者都会被读进后续的帧:
// `gradeRisk.state.tool`、`stepOk.state.tool`、以及 `frame.ts` 里
// `ctx.lastTool === 'write_file'` 那个分支。
// 只回退一个,下一轮判定就会看到一个**从未发生过的调用** —— 帧在说谎,
// 而下游每个判定都会"正确地"基于它做判断(同 A1 的失效形状)。
ctx.lastTool = ctx.history.at(-1)?.tool
break
}
} else {
trace(` cleared: ${tool} (${risk.action})`)
// `auto_audit` 承诺了留痕,那留痕就必须真的发生 ——
// 以前这条分支和 `auto` 完全一样,只多打一行 trace。
if (risk.action === 'auto_audit') {
meter.recordAudit(step, {
tool,
target: input.length > 200 ? `${input.slice(0, 200)}…` : input,
reason: risk.reason,
risk: risk.answers.risk.score,
})
emit({ type: 'audit', step, record: meter.audit[meter.audit.length - 1]! })
trace(` audit trail #${meter.audit.length}: ${tool} risk=${risk.answers.risk.score}`)
}
}
// ── 唯一有真实副作用的地方 ──
emit({ type: 'tool:call', step, tool, input })
const toolT0 = Date.now()
const result = await callTool(tools, tool, input, ctx.cwd, { isolated: opts.assumeIsolated === true })
emit({ type: 'tool:result', step, tool, output: result, ms: Date.now() - toolT0 })
pending.result = result
ctx.lastResult = result
// 工具产生了文件列表 → 灌进 ctx,下一轮的候选动作会跟着变
if (tool === 'list_dir') {
ctx.files = result.split('\n').filter((l) => l && !l.endsWith('/'))
}
// 读过的文件要记下来 —— 否则 `pickInput` 会一直提议读同一个文件
if (tool === 'read_file') {
ctx.readFiles = [...(ctx.readFiles ?? []), input.trim()]
}
/*
↗ 成功了吗
⚠️ **这两个(`stepOk` / `isDone`)不能合并** —— 试过了,合并检查当场拦下,
而且拦得对。
逻辑上它们确实独立(一个问这次调用,一个问整个任务),但**帧必须不一样**:
`stepOk` **故意没有 `task`** —— 那是修出来的。以前帧带着 `task`、
问题写着 "for the task",于是第一步 `list_dir` 成功返回了
文件列表,它却因为「没回答任务的问题」判 `ok=0.470` → `stop`
→ 任何多步任务都跑不完(见 DECISION.md 的 step_ok 一节)。
`isDone` **必须有 `task`** —— 它判的就是任务完没完。
合并会把 `task` 塞回 `stepOk` 的帧里,**把那个修复撤销掉**。
(拦下它的直接原因还不是 `task`,是 `already_read`:`stepOk` 放的是
**条数**,`isDone` 放的是**清单**。同一个字段名两种东西 —— 那本身也是
个该修的毛病,记在案。)
*/
const ok = await ask(specs.stepOk, ctx)
if (ok.action !== 'continue') {
halt = 'step_failed'
trace(` this step did not succeed → stopping (${ok.reason})`)
break
}
// ↗ 做完了吗
const done = await ask(specs.isDone, ctx)
if (done.action === 'finish') {
halt = 'task_done'
break
}
}
// ── 生成(整个 loop 里唯一贵的一步)────────────────────────
/**
* 交给生成器的证据。**必须有界,而且必须承认自己被截过。**
*
* 以前这里没有任何上界:`ctx.history` 有多长证据就有多长 ——
* `maxSteps` 是 12、单次工具结果最多 4000 字符(`read_file` 的截断),
* 最坏能到约 200KB。
*
* 为什么没人发现:它进的是**生成请求**(LLM 侧),**不是决策帧**,
* 所以 `budget.ts` 的 `validate()` 管不到它 —— 帧有预算、证据没有。
* 这是个遗漏,不是有意的设计。
*
* ── 规则在 `context.ts`,不在这里 ──────────────────────────
*
* 这里原来有一份内联实现(单阈值 6000 / 每条 800 / 只留头)。它和
* `context.ts` 是同一件事的两份实现(docs/CODE-STYLE.md §3.1),已按所有者指示合并:
* **位置取这里**(每条工具结果进 history 的那一层,粒度对,而且判定帧
* 以后能共用同一份账),**规则取 `context.ts`**。
*
* 取它那套的三条理由,都是「不报错但一直在错」的情形:
*
* · **留头也留尾** —— 工具输出最有用的两端是开头(这是什么)和结尾
* (错误、汇总)。只留头会把错误信息砍掉。
* · `head + 标记 + tail ≤ 阈值`,**配置期校验** —— 挡「越裁越大」。
* · `retain < trigger` 且 `retain ≥ 单条裁剪后的最大体积` ——
* 分别挡「每轮都压、永远压不下去」和「目标线永远达不到」。
*
* 账目(剪了几条、丢了几条、有没有压到目标)由 `fitEvidence` 返回,
* 这里存进 `lastEvidence`,随 `AgentResult` 交给调用方显示 ——
* §8.10:被丢掉的东西要报出来,否则读起来就像"本来就这些"。
*/
const EVIDENCE_INPUT_CHARS = 120
/**
* `write_file` 的输入预算 —— **比别的工具宽得多**。
*
* ══════════════════════════════════════════════════════════════
* ★ **预算要跟着载荷走,不能对所有输入一视同仁。**
* ══════════════════════════════════════════════════════════════
*
* 对 `read_file` 来说输入是**文件名**(本来就短),内容在**结果**里;
* 对 `write_file` 正好相反 —— 输入是 `路径\n内容`,结果只有一句
* 「已写入 X(N 字符)」。用同一个 120 去切,切掉的正是**唯一有信息的那半**。
*
* 实测(2026-09-21)这条链是怎么塌的:
*
* ① 写入的内容约 131 字符 → 被切成 `…[+22]`
* ② 生成器看到的是**残缺的**写入记录,于是写出一份诚实的、带保留的回答:
* 「写入内容在记录中被截断,因此不能确认 `summary.ts` 的完整内容」
* ③ `canDeliver` 判这份回答没完成任务 → `revise` —— **它判得没错**
* ④ 修订那版更保守 → 更不像交付 → 再 revise → 停机
*
* 表面上是 `canDeliver` 的命中率只有 9%,**而它每一轮都判对了**。
* 病在它上游:喂给生成器的那份证据是残缺的(§8.2 的老问题,
* 只不过这次是**生成**的帧而不是**判定**的帧)。
*/
const EVIDENCE_WRITE_INPUT_CHARS = 600
/**
* 备好证据,并把账目**一起返回**。
*
* ★ 以前它只返回文本,账目写进一个外层的 `let lastEvidence`。那是
* 「调用多次、只留最后一次」的写法 —— 而它**只被调一次**(下面那段
* 注释解释了为什么必须只调一次)。副作用改成返回值之后,类型是确定的
* (不再是 `ContextReport | undefined`),也不用再断言「它一定有值」。
*/
const buildEvidence = (): { text: string; report: ContextReport } => {
// `label` 是给折叠摘要用的短名字 —— `context.ts` 不认识工具,所以由这里给
const parts = (ctx.history ?? []).map((x) => ({
// 写操作的载荷在**输入**里(见上面那个常量的说明)
text: `${x.tool}(${clip(x.input, x.tool === 'write_file' ? EVIDENCE_WRITE_INPUT_CHARS : EVIDENCE_INPUT_CHARS)}) → ${x.result}`,
label: x.input ? `${x.tool}(${clip(x.input, 60)})` : x.tool,
}))
const { text, folds, report } = foldEvidence(parts, EVIDENCE_POLICY)
// `context` 事件**仍然只在真的动手时发** —— 它是轨迹里的一条记录,
// 「什么都没做」不该占一行。而「没动手时也要看得见预算」由 `run:end`
// 上那份**每轮都发**的 `budget` 负责,两者分工不同。
if (report.acted) {
emit({
type: 'context',
step,
rawChars: report.rawChars,
keptChars: report.keptChars,
prunedCount: report.prunedCount,
foldedCount: report.foldedCount,
overRetain: report.overRetain,
folds: folds.map((f) => ({
toSeq: f.toSeq,
foldedNodes: f.foldedNodes,
removedChars: f.removedChars,
})),
})
}
return { text, report }
}
// ★ 证据**只算一次**。
//
// `evidence()` 里有一次折叠(重活),而它原本被调了两遍 ——
// 一遍给生成、一遍给定价 —— 加上 revise 那一轮一共 4 遍。
// 后果不只是白算:每算一遍就发一个 `context` 事件,于是界面和轨迹里
// 出现 4 条一模一样的账目。
//
// 循环已经结束,`ctx.history` 在两次生成之间不会变,所以一次就够。
const { text: evidenceText, report: evidenceReport } = buildEvidence()
const evidenceEstimate = priceGenerateRequest({
task: ctx.task,
evidence: evidenceText,
history: genHistory,
historyDigest: genDigest,
})
let genStep = step + 1
decider.setStep(genStep)
/*
★ 生成之前**再检查一次**上限。
只在循环头上检查是不够的:循环是因为 `max_steps` 正常退出时,后面还有
**整个 loop 里最贵的那一步**(生成,外加可能的 revise 再来一次)。
「越线就不再花钱」这句话要是漏掉这里,就是漏掉了花钱最多的地方。
越线时**不生成**,直接把已有的草稿(通常没有)当答案交出去,并在 halt
里说清是撞了哪条上限 —— 一次空回答加一句真话,好过一份超支的报告。
*/
const overBeforeGenerate = spent()
if (overBeforeGenerate) {
halt = overBeforeGenerate
opts.onTrace?.(` budget reached (${overBeforeGenerate}) → not generating`)
emit({
type: 'run:end',
halt,
steps: step,
answer: ctx.draft ?? '',
stats: meter.stats,
budget: runBudget(evidenceReport, folded.report, evidenceEstimate),
})
return {
answer: ctx.draft ?? '',
halt,
steps: step,
ctx,
meter,
context: evidenceReport,
conversation: folded.report,
request: evidenceEstimate,
}
}
emit({ type: 'phase', step: genStep, kind: 'generate' })
const gen = await generator.generate({
task: ctx.task,
evidence: evidenceText,
// ★ 用 `genHistory`/`genDigest`(空就传 `undefined`),**不是** `folded.recent`。
// 实测:这两个写法在这里分过叉 —— 第一次生成传的是空数组 `[]`,
// 而修订那次传的是 `undefined`,同一件事两个值。
// 今天对 `HttpGenerator` / `ScriptedGenerator` 没有行为差别
// (两边都写 `req.history ?? []`),但**契约是「缺省就是缺省」**,
// 而下面的测试原本看不见这次分叉(见那条测试的注释)。
history: genHistory,
historyDigest: genDigest,
onDelta: deltaFor(genStep),
})
meter.recordModelCall(genStep, {
kind: `generate (${generator.name})`,
latencyMs: gen.latencyMs,
inputTokens: gen.inputTokens,
outputTokens: gen.outputTokens,
})
emit({
type: 'generate',
step: genStep,
kind: `generate (${generator.name})`,
latencyMs: gen.latencyMs,
inputTokens: gen.inputTokens,
outputTokens: gen.outputTokens,
estimatedInputTokens: evidenceEstimate.controlTokens,
})
ctx.draft = gen.text
// ↗ 能交付吗
let deliver = await ask(specs.canDeliver, ctx)
// `revise` 承诺了「修订」,那修订就必须真的发生 ——
// 以前它只是被拼进 halt 字符串,草稿原样返回。
// **上限 1 次**:第二次还不合格就如实返回并说明,不无限重试(那会变成一个收费循环)。
if (deliver.action === 'revise') {
trace(` the delivery gate asked for a revision (${deliver.reason}) → regenerating once with that feedback`)
genStep += 1
decider.setStep(genStep)
emit({ type: 'phase', step: genStep, kind: 'generate' })
const retry = await generator.generate({
task: ctx.task,
// 同一份证据和同一份上文 —— 循环早就结束了,两者都没变过
evidence: evidenceText,
history: genHistory,
historyDigest: genDigest,
instruction: `上一次的回答没有通过交付闸门:${deliver.reason}。请据此修正,不要重复同样的写法。`,
onDelta: deltaFor(genStep),
/*
★ 告诉生成器**这是一次修订**,理由原样带过去。
它是给**界面**用的:修订意味着上一次流出去的回答整个作废,那些字
会被擦掉。一声不吭地擦掉几百字正是「这个功能坏了」的样子
(见 `GenDelta.resetWhy`)。
*/
revise: deliver.reason,
})
meter.recordModelCall(genStep, {
kind: `generate/revise (${generator.name})`,
latencyMs: retry.latencyMs,
inputTokens: retry.inputTokens,
outputTokens: retry.outputTokens,
})
emit({
type: 'generate',
step: genStep,
kind: `generate/revise (${generator.name})`,
latencyMs: retry.latencyMs,
inputTokens: retry.inputTokens,
outputTokens: retry.outputTokens,
estimatedInputTokens: evidenceEstimate.controlTokens,
})
ctx.draft = retry.text
deliver = await ask(specs.canDeliver, ctx)
}
if (deliver.action !== 'deliver') {
halt = `${halt}+${deliver.action}`
}
emit({
type: 'run:end',
halt,
steps: step,
answer: ctx.draft,
stats: meter.stats,
budget: runBudget(evidenceReport, folded.report, evidenceEstimate),
})
return {
answer: ctx.draft,
halt,
steps: step,
ctx,
meter,
// 两份账目**都给**,不管有没有动手 —— 「没动手」本身是一条信息
// (离触发线还有多远),而「只报出事的那种」让正常运行里看不见预算。
context: evidenceReport,
conversation: folded.report,
request: evidenceEstimate,
}
}
/**
* 把两块预算的报告 + 这次请求的 token 构成,压成事件和界面要的那一个形状。
*
* ── 为什么在这里映射,而不是让界面认两种报告 ────────────────────
*
* 两块报告的形状不同:一块数**步**(`ContextReport`,有 `prunedCount` /
* `foldedCount`),一块数**轮**(`ConversationReport`,有 `foldedTurns`)。
* 但界面要回答的是**同一组问题**:用了多少、线在哪、动手没有、动的是什么。
*
* 让界面去认两种形状,等于以后加第三块预算(比如决策帧)时还要改界面。
* 所以在这一层一次性映射:**新增一块预算,界面不用动**。
*
* @param evidence 工具证据那块(单位:步)
* @param conversation 上文那块(单位:轮)
* @param request 这次请求的 token 构成
*/
function runBudget(
evidence: ContextReport | undefined,
conversation: ConversationReport,
request: RequestEstimate,
): RunBudget {
return {
// `evidence` 理论上有值(`evidence()` 无条件被调用),但这个函数是
// 纯映射、不该假设调用顺序,所以缺了就给一份全 0 的账而不是抛。
evidence: budgetLine(
evidence ?? {
rawChars: 0,
keptChars: 0,
prunedCount: 0,
foldedCount: 0,
triggerChars: 0,
retainChars: 0,
acted: false,
overRetain: false,
},
evidence ? noteForEvidence(evidence) : '',
),
conversation: budgetLine(conversation, noteForConversation(conversation)),
request: {
evidence: request.evidenceTokens,
history: request.historyTokens,
task: request.taskTokens,
total: request.controlTokens,
},
}
}
/** 报告 → 事件形状。两块报告都有的那七个字段逐个搬 */
function budgetLine(
r: {
rawChars: number
keptChars: number
triggerChars: number