agent-harness-rs 0.2.3

Agent loop harness with local and sandbox tool runtimes, context management, and MCP support
Documentation
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
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agent-harness-rs · 架构讲解</title>
<script>
  // apply-before-paint: 避免深色模式闪烁
  (function () {
    try {
      var saved = localStorage.getItem("ahr-theme");
      var dark = saved ? saved === "dark"
                       : matchMedia("(prefers-color-scheme: dark)").matches;
      if (dark) document.documentElement.classList.add("dark");
    } catch (e) {}
  })();
</script>
<style>
  :root {
    --ivory:   #FAF9F5;
    --paper:   #ffffff;
    --slate:   #141413;
    --clay:    #D97757;
    --oat:     #E3DACC;
    --olive:   #788C5D;
    --sky:     #6A8CAF;
    --gold:    #C2A83E;
    --rust:    #B04A3F;
    --gray-150:#F0EEE6;
    --gray-300:#D1CFC5;
    --gray-500:#87867F;
    --gray-700:#3D3D3A;
    --text:    #3D3D3A;
    --line:    #D1CFC5;
    --chip:    #F0EEE6;
    --shadow:  0 1px 2px rgba(20,20,19,0.04), 0 8px 24px rgba(20,20,19,0.05);
    --serif: ui-serif, Georgia, "Times New Roman", serif;
    --sans:  system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, sans-serif;
    --mono:  ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  }
  html.dark {
    --ivory:   #1A1916;
    --paper:   #211F1B;
    --slate:   #F5F3EC;
    --clay:    #E0875F;
    --oat:     #2E2B25;
    --olive:   #97AB78;
    --sky:     #86A6C7;
    --gold:    #D3B95A;
    --rust:    #D9756A;
    --gray-150:#262420;
    --gray-300:#3A3731;
    --gray-500:#9A988F;
    --gray-700:#CFCCC3;
    --text:    #CFCCC3;
    --line:    #36332D;
    --chip:    #2A2722;
    --shadow:  0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html { scroll-behavior: smooth; }
  body {
    background: var(--ivory);
    color: var(--text);
    font-family: var(--sans);
    font-size: 15px;
    line-height: 1.7;
    -webkit-font-smoothing: antialiased;
    padding: 0 24px 140px;
    transition: background 200ms ease, color 200ms ease;
  }
  .page { max-width: 1120px; margin: 0 auto; }

  /* ── top bar ─────────────────────────── */
  .topbar {
    max-width: 1120px;
    margin: 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 22px 0 0;
  }
  .brand {
    font-family: var(--mono);
    font-size: 12px;
    letter-spacing: 0.04em;
    color: var(--gray-500);
  }
  .brand b { color: var(--slate); font-weight: 600; }
  .toggle {
    appearance: none;
    border: 1.5px solid var(--line);
    background: var(--paper);
    color: var(--gray-700);
    border-radius: 8px;
    font-family: var(--mono);
    font-size: 12px;
    padding: 7px 12px;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 7px;
    transition: background 150ms, border-color 150ms;
  }
  .toggle:hover { background: var(--gray-150); border-color: var(--gray-300); }

  /* ── header ──────────────────────────── */
  header.hero { padding: 48px 0 8px; }
  .eyebrow {
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--clay);
    margin-bottom: 14px;
  }
  h1 {
    font-family: var(--serif);
    font-weight: 500;
    font-size: 40px;
    color: var(--slate);
    letter-spacing: -0.015em;
    line-height: 1.15;
    margin-bottom: 16px;
  }
  .lead { max-width: 720px; font-size: 16.5px; }
  .lead code { color: var(--clay); }
  .meta-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 22px;
  }
  .chip {
    font-family: var(--mono);
    font-size: 11.5px;
    background: var(--chip);
    border: 1px solid var(--line);
    border-radius: 999px;
    padding: 4px 11px;
    color: var(--gray-700);
  }
  .chip b { color: var(--slate); }

  /* ── sections ────────────────────────── */
  section { padding-top: 8px; }
  h2 {
    font-family: var(--serif);
    font-weight: 500;
    font-size: 25px;
    color: var(--slate);
    margin: 56px 0 6px;
    letter-spacing: -0.01em;
  }
  h2 .num {
    font-family: var(--mono);
    font-size: 13px;
    color: var(--clay);
    margin-right: 10px;
    vertical-align: 3px;
  }
  .sub { color: var(--gray-500); max-width: 680px; margin-bottom: 18px; }
  p { margin-bottom: 12px; max-width: 720px; }
  code { font-family: var(--mono); font-size: 13px; color: var(--slate); }
  .term {
    border-bottom: 1.5px dotted var(--clay);
    cursor: help;
    color: var(--slate);
  }
  a.lnk { color: var(--sky); text-decoration: none; border-bottom: 1px solid transparent; }
  a.lnk:hover { border-bottom-color: var(--sky); }

  /* ── layered architecture diagram ────── */
  .stack { display: grid; gap: 14px; margin: 18px 0 8px; }
  .layer {
    border: 1.5px solid var(--line);
    border-radius: 14px;
    background: var(--paper);
    padding: 16px 18px;
    box-shadow: var(--shadow);
  }
  .layer .lhead {
    display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px;
  }
  .layer .lhead .t { font-family: var(--serif); font-size: 17px; color: var(--slate); }
  .layer .lhead .d { font-size: 12.5px; color: var(--gray-500); }
  .row { display: grid; gap: 12px; }
  .row.c2 { grid-template-columns: 1fr 1fr; }
  .row.c3 { grid-template-columns: 1fr 1fr 1fr; }
  .row.c4 { grid-template-columns: repeat(4, 1fr); }
  @media (max-width: 820px) { .row.c2,.row.c3,.row.c4 { grid-template-columns: 1fr 1fr; } }
  @media (max-width: 560px) { .row.c2,.row.c3,.row.c4 { grid-template-columns: 1fr; } }

  .box {
    border: 1.5px solid var(--line);
    border-radius: 10px;
    background: var(--ivory);
    padding: 12px 13px;
  }
  .box.accent-clay  { border-left: 3px solid var(--clay); }
  .box.accent-olive { border-left: 3px solid var(--olive); }
  .box.accent-sky   { border-left: 3px solid var(--sky); }
  .box.accent-gold  { border-left: 3px solid var(--gold); }
  .box .bt { font-family: var(--mono); font-size: 12.5px; color: var(--slate); font-weight: 600; }
  .box .bd { font-size: 12px; color: var(--gray-500); line-height: 1.5; margin-top: 3px; }
  .box .impl { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
  .box .impl span {
    font-family: var(--mono); font-size: 10.5px;
    background: var(--chip); border: 1px solid var(--line);
    border-radius: 5px; padding: 2px 7px; color: var(--gray-700);
  }
  .flowdown { text-align: center; color: var(--gray-300); font-size: 18px; line-height: 0.6; }

  /* ── interactive turn-loop stepper ───── */
  .loop {
    border: 1.5px solid var(--line);
    border-radius: 16px;
    background: var(--paper);
    padding: 22px;
    margin: 18px 0;
    box-shadow: var(--shadow);
    display: grid;
    grid-template-columns: 300px 1fr;
    gap: 26px;
  }
  @media (max-width: 760px) { .loop { grid-template-columns: 1fr; } }
  .steplist { display: flex; flex-direction: column; gap: 4px; }
  .step {
    display: flex; align-items: flex-start; gap: 11px;
    padding: 9px 11px;
    border-radius: 9px;
    cursor: pointer;
    border: 1.5px solid transparent;
    transition: background 130ms, border-color 130ms;
  }
  .step:hover { background: var(--gray-150); }
  .step.active { background: var(--gray-150); border-color: var(--gray-300); }
  .step .sn {
    flex: none;
    width: 24px; height: 24px;
    border-radius: 50%;
    background: var(--chip);
    border: 1.5px solid var(--line);
    color: var(--gray-500);
    font-family: var(--mono);
    font-size: 12px;
    display: grid; place-items: center;
    transition: all 130ms;
  }
  .step.active .sn { background: var(--clay); border-color: var(--clay); color: #fff; }
  .step .st { font-size: 13.5px; color: var(--gray-700); line-height: 1.35; padding-top: 2px; }
  .step.active .st { color: var(--slate); font-weight: 600; }

  .detail {
    border-left: 1.5px solid var(--line);
    padding-left: 26px;
    min-height: 230px;
  }
  @media (max-width: 760px) { .detail { border-left: none; padding-left: 0; border-top: 1.5px solid var(--line); padding-top: 18px; } }
  .detail .dtag {
    font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em;
    text-transform: uppercase; color: var(--clay); margin-bottom: 8px;
  }
  .detail .dh { font-family: var(--serif); font-size: 21px; color: var(--slate); margin-bottom: 10px; }
  .detail .db { font-size: 14px; color: var(--gray-700); margin-bottom: 14px; max-width: 560px; }
  .detail pre {
    background: var(--ivory);
    border: 1.5px solid var(--line);
    border-radius: 10px;
    padding: 13px 15px;
    overflow-x: auto;
    font-family: var(--mono);
    font-size: 12px;
    line-height: 1.6;
    color: var(--gray-700);
  }
  .detail pre .k { color: var(--clay); }
  .detail pre .c { color: var(--gray-500); font-style: italic; }
  .detail pre .s { color: var(--olive); }
  .navbtns { margin-top: 16px; display: flex; gap: 8px; }
  .navbtns button {
    appearance: none; cursor: pointer;
    border: 1.5px solid var(--line); background: var(--ivory);
    border-radius: 8px; font-family: var(--mono); font-size: 12px;
    padding: 7px 13px; color: var(--gray-700);
    transition: background 130ms;
  }
  .navbtns button:hover { background: var(--gray-150); }
  .navbtns button:disabled { opacity: 0.4; cursor: default; }

  /* ── events grid ─────────────────────── */
  .events { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; margin: 16px 0; }
  @media (max-width: 760px) { .events { grid-template-columns: 1fr 1fr; } }
  @media (max-width: 480px) { .events { grid-template-columns: 1fr; } }
  .ev { border: 1.5px solid var(--line); border-radius: 11px; background: var(--paper); padding: 13px 14px; }
  .ev .en { font-family: var(--mono); font-size: 12.5px; color: var(--slate); font-weight: 600; }
  .ev .ed { font-size: 12px; color: var(--gray-500); margin-top: 4px; line-height: 1.5; }
  .ev .dot { display:inline-block; width:7px; height:7px; border-radius:50%; margin-right:7px; vertical-align: 1px; }

  /* ── table ───────────────────────────── */
  .tablewrap { overflow-x: auto; margin: 16px 0; border: 1.5px solid var(--line); border-radius: 12px; }
  table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
  th, td { text-align: left; padding: 11px 16px; border-bottom: 1px solid var(--line); white-space: nowrap; }
  tr:last-child td { border-bottom: none; }
  th {
    font-family: var(--mono); font-size: 11px; text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--gray-500); font-weight: 500;
    background: var(--gray-150);
  }
  td.mono { font-family: var(--mono); font-size: 12.5px; color: var(--slate); }
  td .role { color: var(--gray-700); white-space: normal; }
  .bar { display:inline-block; height:7px; border-radius:3px; background: var(--clay); vertical-align: middle; margin-right: 8px; opacity: 0.85; }
  td .loc { font-family: var(--mono); font-size: 12px; color: var(--gray-500); }

  /* ── callouts ────────────────────────── */
  .callouts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; }
  @media (max-width: 720px) { .callouts { grid-template-columns: 1fr; } }
  .callout {
    border: 1.5px solid var(--line);
    border-radius: 12px;
    background: var(--paper);
    padding: 15px 17px;
    position: relative;
  }
  .callout::before {
    content: "";
    position: absolute; left: 0; top: 16px; bottom: 16px;
    width: 3px; border-radius: 3px; background: var(--clay);
  }
  .callout.c-olive::before { background: var(--olive); }
  .callout.c-sky::before { background: var(--sky); }
  .callout.c-gold::before { background: var(--gold); }
  .callout h4 { font-family: var(--serif); font-size: 16px; color: var(--slate); margin-bottom: 6px; font-weight: 500; }
  .callout p { font-size: 13px; color: var(--gray-700); margin: 0; }
  .callout code { font-size: 12px; }

  /* ── glossary tooltip ────────────────── */
  #tip {
    position: fixed;
    z-index: 50;
    max-width: 280px;
    background: var(--slate);
    color: var(--ivory);
    font-size: 12.5px;
    line-height: 1.5;
    padding: 9px 12px;
    border-radius: 9px;
    box-shadow: 0 6px 24px rgba(0,0,0,0.25);
    pointer-events: none;
    opacity: 0;
    transform: translateY(4px);
    transition: opacity 120ms, transform 120ms;
  }
  #tip.on { opacity: 1; transform: translateY(0); }
  #tip code { color: var(--clay); background: rgba(255,255,255,0.08); padding: 0 3px; border-radius: 3px; }

  footer {
    margin-top: 64px; padding-top: 22px;
    border-top: 1px solid var(--line);
    font-size: 12.5px; color: var(--gray-500);
    display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px;
  }
  footer code { color: var(--gray-700); }
</style>
</head>
<body>

<div class="topbar">
  <div class="brand"><b>agent-harness-rs</b> &nbsp;·&nbsp; v0.2.2 &nbsp;·&nbsp; MIT</div>
  <button class="toggle" id="themeBtn" aria-label="切换主题">
    <span id="themeIcon"></span><span id="themeLabel">深色</span>
  </button>
</div>

<div class="page">

  <header class="hero">
    <div class="eyebrow">Rust crate · 架构讲解</div>
    <h1>一个 LLM Agent 的回合循环,<br>拆开来看</h1>
    <p class="lead">
      <code>agent-harness-rs</code> 是一个构建 LLM 编码 Agent 的运行时骨架。它的核心是一个
      <span class="term" data-t="turn">回合循环</span>:把对话历史交给模型,流式接收回复,遇到
      <span class="term" data-t="toolcall">工具调用</span>就并发执行、把结果塞回历史,再喂给模型——直到模型说"我说完了"。
      围绕这个循环,它把<b>模型</b><b>工具</b>都抽象成可替换的 trait,并叠加了重试、压缩、持久化、取消、MCP 等横切能力。
    </p>
    <div class="meta-row">
      <span class="chip"><b>17.3k</b> 行 Rust</span>
      <span class="chip"><b>19</b> 个源文件</span>
      <span class="chip">2 个核心 trait:<b>ModelClient</b> · <b>ToolRuntime</b></span>
      <span class="chip">运行时:<b>local</b> · <b>sandbox</b> · <b>e2b</b></span>
      <span class="chip">特性门:<b>local-tools</b> · <b>e2b</b></span>
    </div>
  </header>

  <!-- ═══════════ 1. 分层全景 ═══════════ -->
  <section>
    <h2><span class="num">01</span>分层全景</h2>
    <p class="sub">
      调用方只跟 <code>AgentLoopHarness</code> 打交道。harness 把"怎么跟模型说话"和"怎么执行工具"分别委托给两个 trait,
      自己只负责编排。横切关注点(压缩 / 上下文 / 事件 / 技能 / 修复 / 风险分级)各自独立成模块。
    </p>

    <div class="stack">
      <!-- caller -->
      <div class="layer">
        <div class="lhead"><span class="t">调用方</span><span class="d">RD / HR / 你的程序 — 通过 mpsc 通道消费事件</span></div>
        <div class="box accent-clay">
          <div class="bt">NativeTurnInput</div>
          <div class="bd"><code>prompt_text</code> · <code>system_prompt</code> · <code>attachments</code> · <code>cancel_token</code> · <code>prior_messages</code><code>context_path</code></div>
        </div>
      </div>
      <div class="flowdown"></div>

      <!-- orchestrator -->
      <div class="layer">
        <div class="lhead"><span class="t">编排层 · AgentLoopHarness</span><span class="d">run_turn → run_loop &nbsp;(<code>agent_loop.rs</code>)</span></div>
        <div class="row c4">
          <div class="box accent-clay"><div class="bt">回合循环</div><div class="bd">逐 step 推进,最多 <code>max_steps</code></div></div>
          <div class="box accent-clay"><div class="bt">建链重试 ×3</div><div class="bd"><code>stream()</code> 建立前的瞬时错误退避重试</div></div>
          <div class="box accent-clay"><div class="bt">流内重连 ×6</div><div class="bd">仅在<b>尚无输出</b>时重连,避免重复</div></div>
          <div class="box accent-clay"><div class="bt">取消点 ×3</div><div class="bd">step 前 / 派发前 / 每个 chunk</div></div>
        </div>
      </div>

      <div class="row c2" style="margin-top:14px;">
        <!-- model side -->
        <div class="layer">
          <div class="lhead"><span class="t">trait ModelClient</span><span class="d">流式说话方 · <code>model.rs</code></span></div>
          <div class="box accent-olive">
            <div class="bt">stream(ModelTurnInput) → ModelChunk 流</div>
            <div class="bd">TextDelta / ThinkingDelta / ToolCall* / Done — 与具体厂商线格式解耦</div>
            <div class="impl">
              <span>OpenAiCompatibleModelClient</span>
              <span>AnthropicModelClient</span>
              <span>ScriptedModelClient(测试)</span>
            </div>
          </div>
        </div>
        <!-- tool side -->
        <div class="layer">
          <div class="lhead"><span class="t">trait ToolRuntime</span><span class="d">工具执行方 · <code>tools/</code></span></div>
          <div class="box accent-sky">
            <div class="bt">specs() · invoke() · invoke_cancellable() · repair_invocation()</div>
            <div class="bd">harness 不关心工具跑在哪 —— 本机、远程沙箱还是 MCP server</div>
            <div class="impl">
              <span>LocalToolRuntime</span>
              <span>SandboxToolRuntime&lt;E&gt;</span>
              <span>E2bToolRuntime</span>
              <span>McpToolRuntime</span>
              <span>CompositeToolRuntime</span>
              <span>BoundedToolRuntime&lt;R&gt;</span>
            </div>
          </div>
        </div>
      </div>

      <!-- cross-cutting -->
      <div class="layer">
        <div class="lhead"><span class="t">横切能力</span><span class="d">独立模块,循环在恰当时机调用</span></div>
        <div class="row c3">
          <div class="box accent-gold"><div class="bt">compaction</div><div class="bd">步间检查 → 摘要折叠历史,<b>永不让回合失败</b></div></div>
          <div class="box accent-gold"><div class="bt">context::jsonl</div><div class="bd">JSONL 持久化:增量 append + 压缩时 rewrite</div></div>
          <div class="box accent-gold"><div class="bt">event</div><div class="bd">HarnessInternalEvent —— 对外的事件契约</div></div>
          <div class="box accent-gold"><div class="bt">tool_repair</div><div class="bd">按 schema 修弱模型的入参 + 截断 JSON 修复</div></div>
          <div class="box accent-gold"><div class="bt">shell_risk</div><div class="bd">bash 命令风险分级</div></div>
          <div class="box accent-gold"><div class="bt">skills</div><div class="bd">渐进式披露的技能目录注入提示词</div></div>
        </div>
      </div>
    </div>
  </section>

  <!-- ═══════════ 2. 回合循环 ═══════════ -->
  <section>
    <h2><span class="num">02</span>一个回合,逐步拆解</h2>
    <p class="sub">
      这是 <code>run_loop</code> 的主体——一个 <code>for step in 0..</code> 循环。点击左侧任意步骤查看它在做什么。
      循环的两个出口:模型给出纯文本回复(<code>end_turn</code>),或步数耗尽(<code>max_turns</code>);中途还可因取消或硬错误提前结束。
    </p>

    <div class="loop">
      <div class="steplist" id="steplist"><!-- JS 注入 --></div>
      <div class="detail" id="detail"><!-- JS 注入 --></div>
    </div>
  </section>

  <!-- ═══════════ 3. 事件流 ═══════════ -->
  <section>
    <h2><span class="num">03</span>对外只发事件</h2>
    <p class="sub">
      harness 不返回结果对象,而是通过 <code>mpsc::Receiver&lt;Result&lt;HarnessInternalEvent, …&gt;&gt;</code> 实时吐出事件。
      文本和思考是<b>逐 token</b>流出的;工具有完整的生命周期;回合结束携带用量统计和(内存模式下的)完整历史快照。
    </p>
    <div class="events">
      <div class="ev"><div class="en"><span class="dot" style="background:var(--clay)"></span>AssistantTextChunk</div><div class="ed">助手正文增量。同一 step 共享 <code>msg_id</code>,下游可合并成一条消息。</div></div>
      <div class="ev"><div class="en"><span class="dot" style="background:var(--olive)"></span>AssistantThinkingChunk</div><div class="ed">扩展思考增量(Anthropic)。带 signature,需原样回传。</div></div>
      <div class="ev"><div class="en"><span class="dot" style="background:var(--sky)"></span>ToolCall</div><div class="ed"><code>{id, name, input}</code> —— 已经过入参修复后的版本。</div></div>
      <div class="ev"><div class="en"><span class="dot" style="background:var(--sky)"></span>ToolResult</div><div class="ed"><code>output: Result&lt;Value, String&gt;</code> —— 成功值或模型可见的失败串。</div></div>
      <div class="ev"><div class="en"><span class="dot" style="background:var(--gold)"></span>CompactionApplied</div><div class="ed">历史被折叠。携带折叠前后的消息数与估算 token 数。</div></div>
      <div class="ev"><div class="en"><span class="dot" style="background:var(--rust)"></span>TurnEnd</div><div class="ed"><code>stop_reason</code> + <code>usage</code> + <code>final_messages</code>(仅内存模式)。</div></div>
    </div>
    <p style="font-size:13px;color:var(--gray-500);max-width:720px;">
      失败侧用 <code>NativeHarnessError</code> 分桶:限流 / 鉴权 / 上下文溢出 / 网络 / 坏请求 / 5xx / 工具运行时——
      让调用方不必解析字符串就能决定是否重试。
    </p>
  </section>

  <!-- ═══════════ 4. 两个核心 trait ═══════════ -->
  <section>
    <h2><span class="num">04</span>可替换的两端</h2>
    <p class="sub">
      整个 crate 的设计支点:模型和工具都是 trait。换厂商、换执行环境、做测试桩,都不动循环一行代码。
    </p>
    <div class="row c2">
      <div class="layer">
        <div class="lhead"><span class="t" style="font-family:var(--mono);font-size:14px;">trait ModelClient</span></div>
        <p style="font-size:13.5px;max-width:none;">核心方法 <code>stream()</code> 返回一个 <code>ModelChunk</code> 流。harness 在 <code>consume_step_stream</code> 里把 TextDelta 实时转发、把 ToolCall* 累积成调用,最后在 <code>Done</code> 拿到 stop_reason 和用量。</p>
        <div class="impl" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;">
          <span class="chip">OpenAI 兼容(含 GLM 等)</span>
          <span class="chip">Anthropic(含扩展思考)</span>
          <span class="chip">Scripted(确定性测试)</span>
        </div>
      </div>
      <div class="layer">
        <div class="lhead"><span class="t" style="font-family:var(--mono);font-size:14px;">trait ToolRuntime</span></div>
        <p style="font-size:13.5px;max-width:none;"><code>specs()</code> 告诉模型有哪些工具;<code>invoke_cancellable()</code> 执行并支持中途取消(如给 bash 子进程发 SIGTERM);<code>repair_invocation()</code> 在派发前按 schema 修正入参。</p>
        <div class="impl" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;">
          <span class="chip">Local(本机 bash/read/write/edit/glob/grep)</span>
          <span class="chip">Sandbox&lt;E&gt;(任意远程沙箱)</span>
          <span class="chip">E2b(Connect→envd)</span>
          <span class="chip">MCP + Composite(聚合多源)</span>
        </div>
      </div>
    </div>
    <div class="callouts" style="margin-top:14px;">
      <div class="callout c-sky">
        <h4>BoundedToolRuntime 是装饰器</h4>
        <p>它包裹任意 <code>ToolRuntime</code>,统一做两件事:把超大输出落盘到 <code>/tmp</code> 并只回传预览;作为入参修复的<b>单一真相源</b>。循环在记录历史前先调一次 repair,装饰器派发时再幂等地调一次,保证历史、线格式、实际执行三者一致。</p>
      </div>
      <div class="callout">
        <h4>CompositeToolRuntime 聚合多源</h4>
        <p>把本地/沙箱工具与多个 MCP server 的工具合并成一个 specs 列表,按工具名路由 <code>invoke</code>。这是接入 MCP 的入口。</p>
      </div>
    </div>
  </section>

  <!-- ═══════════ 5. 模块地图 ═══════════ -->
  <section>
    <h2><span class="num">05</span>模块地图</h2>
    <p class="sub">按代码量排序。条宽 ∝ 行数,给你一个"重量分布"的直观感受。</p>
    <div class="tablewrap">
      <table id="modtable">
        <thead><tr><th>模块</th><th>职责</th><th style="text-align:right">行数</th></tr></thead>
        <tbody><!-- JS 注入 --></tbody>
      </table>
    </div>
  </section>

  <!-- ═══════════ 6. 设计决策 ═══════════ -->
  <section>
    <h2><span class="num">06</span>几个值得记住的设计决策</h2>
    <div class="callouts">
      <div class="callout">
        <h4>厂商无关的领域库</h4>
        <p>harness crate 刻意<b>不依赖任何 grpc / proto</b><code>HarnessUsage</code><code>HarnessInternalEvent</code> 都是纯领域类型,由外层 <code>native_adapter</code> 负责投射到线格式。</p>
      </div>
      <div class="callout c-olive">
        <h4>压缩永不拖垮回合</h4>
        <p>步间压缩是<b>纯增量</b>的:摘要失败就原样保留历史,让下一步/下一回合再试。最坏情况退化为"上下文溢出"这个模型错误,由调用方决策。</p>
      </div>
      <div class="callout c-sky">
        <h4>持久 vs 内存 两种模式</h4>
        <p><code>context_path = Some</code> 时从 JSONL 加载历史、增量追加、压缩时重写,<code>final_messages</code> 为空;<code>None</code> 时用 <code>prior_messages</code> 作种子,永不碰磁盘,历史快照随 <code>TurnEnd</code> 带回。</p>
      </div>
      <div class="callout c-gold">
        <h4>取消的三个落点</h4>
        <p>每个 step 前、工具派发前、以及 <code>consume_step_stream</code> 里每次等 chunk 的 <code>select!</code>。工具以独立 task 派发,取消时立刻回 <code>TurnEnd{interrupt}</code>,而取消感知的运行时仍能继续给远程进程发信号。</p>
      </div>
      <div class="callout">
        <h4>两条独立的重试预算</h4>
        <p>建链失败(请求层瞬时故障)重试 <code>MAX_RETRIES=3</code>;流内停顿/断开重连最多 <code>6</code> 次,但<b>仅当还没有任何输出送达用户</b>——一旦开始输出,重发会导致重复,于是中途失败变为终止。</p>
      </div>
      <div class="callout c-olive">
        <h4>工具结果区分两类失败</h4>
        <p><code>ToolFailure</code>(文件不存在、退出码非零、超时、坏入参)是<b>模型可见</b>的,会塞回历史让模型自行恢复;<code>ToolRuntimeError</code>(沙箱不可达等基础设施故障)则直接以 <code>NativeHarnessError::ToolRuntime</code> 终止回合。</p>
      </div>
    </div>
  </section>

  <footer>
    <span>源:<code>/Users/a1/ruantong/agent-harness-rs</code> · 入口 <code>src/agent_loop.rs::run_loop</code></span>
    <span>提示:悬停正文里的<span class="term" data-t="dotted">虚线词</span>看术语解释 · 右上角切换深色模式</span>
  </footer>

</div>

<div id="tip"></div>

<script>
/* ── 主题切换 ───────────────────────────── */
const root = document.documentElement;
const btn = document.getElementById("themeBtn");
const icon = document.getElementById("themeIcon");
const label = document.getElementById("themeLabel");
function syncTheme() {
  const dark = root.classList.contains("dark");
  icon.textContent = dark ? "" : "";
  label.textContent = dark ? "浅色" : "深色";
}
btn.addEventListener("click", () => {
  root.classList.toggle("dark");
  try { localStorage.setItem("ahr-theme", root.classList.contains("dark") ? "dark" : "light"); } catch (e) {}
  syncTheme();
});
syncTheme();

/* ── 回合循环 stepper ───────────────────── */
const STEPS = [
  {
    t: "播种历史 + 压栈用户消息",
    tag: "step 进入前 · 一次性",
    h: "从哪儿来的历史?",
    b: "持久模式从 JSONL 文件加载先前消息;内存模式直接用 prior_messages。随后把本回合的用户 prompt 作为 User 消息压入,并(持久模式下)增量写盘。",
    code: `<span class="k">let mut</span> messages = <span class="k">match</span> context_path {
  <span class="k">Some</span>(p) => jsonl::load_context(p).<span class="k">await</span>, <span class="c">// </span>
  <span class="k">None</span>    => input.prior_messages,           <span class="c">// </span>
};
messages.push(ChatMessage::User { content, attachments });`
  },
  {
    t: "压缩检查(步间)",
    tag: "step 开头 · 每步",
    h: "历史太长就先折叠",
    b: "若策略判定 should_compact,调用 SummarizeCompactionStrategy 把旧历史摘要成更短的形式。失败不致命——原样保留,发 CompactionApplied 事件,持久模式还会 rewrite JSONL。",
    code: `<span class="k">if</span> policy.strategy.should_compact(&messages, window) {
  <span class="k">match</span> policy.strategy.compact(messages, &cctx).<span class="k">await</span> {
    <span class="k">Ok</span>(out) => { messages = out.messages; emit(CompactionApplied); }
    <span class="k">Err</span>(_)  => { <span class="c">/*  */</span> }
  }
}`
  },
  {
    t: "建立模型流(重试 ×3)",
    tag: "step 中 · 请求层",
    h: "把历史交给模型",
    b: "组装 ModelTurnInput(system + messages + tools + tool_choice),调用 model.stream()。建链阶段的可重试错误按指数退避重试至多 3 次;不可重试错误(鉴权/坏请求/上下文溢出)立即终止。",
    code: `<span class="k">let</span> input = ModelTurnInput { system_prompt, messages,
                              tools, tool_choice, parallel };
<span class="k">loop</span> {
  <span class="k">match</span> model.stream(input.clone()).<span class="k">await</span> {
    <span class="k">Ok</span>(s) => <span class="k">break</span> s,
    <span class="k">Err</span>(e) <span class="k">if</span> e.retryable() && attempt &lt; 3 => backoff(),
    <span class="k">Err</span>(e) => <span class="k">return</span> emit_err(e), <span class="c">// </span>
  }
}`
  },
  {
    t: "消费流 + 看门狗(重连 ×6)",
    tag: "step 中 · 流内",
    h: "逐 token 转发,累积工具调用",
    b: "consume_step_stream 把 TextDelta/ThinkingDelta 实时发往通道,把 ToolCallStart/InputDelta/End 累积成完整调用,直到 Done。空闲看门狗在流停顿时触发;若此前尚无输出送达,可重连至多 6 次。",
    code: `<span class="k">match</span> consume_step_stream(stream, &tx, step, cancel, idle).<span class="k">await</span> {
  <span class="k">Ok</span>(Complete(o))  => <span class="k">break</span> o,
  <span class="k">Ok</span>(Cancelled)    => <span class="k">return</span> emit(TurnEnd{interrupt}),
  <span class="k">Err</span>(Model{err, had_progress}) =>
    <span class="k">if</span> !had_progress && err.retryable() { reconnect() }
    <span class="k">else</span> { <span class="k">return</span> emit_err(err) },
}`
  },
  {
    t: "分支:纯文本 → 结束",
    tag: "step 末 · 出口 A",
    h: "模型说完了",
    b: "若这一步产出的是纯文本(没有工具调用),把它作为最终 Assistant 消息压入历史、持久化,发出 TurnEnd{end_turn} 并返回。这是回合最常见的正常出口。",
    code: `StepNext::Message { text, stop_reason } => {
  messages.push(ChatMessage::Assistant { text, .. });
  <span class="k">if let</span> <span class="k">Some</span>(p) = context_path { jsonl::append(p, &new).<span class="k">await</span>; }
  emit(TurnEnd { stop_reason, usage, final_messages });
  <span class="k">return</span>;
}`
  },
  {
    t: "分支:工具调用 → 修复 + 记录",
    tag: "step 末 · 出口 B",
    h: "模型要用工具",
    b: "派发前先按 schema 修复每个调用的入参(弱模型常把形状搞错),再把带 tool_calls 的 Assistant 消息压入历史,然后按声明顺序发出 ToolCall 事件——保证历史、线格式、实际执行三者一致。",
    code: `<span class="k">for</span> inv <span class="k">in</span> &<span class="k">mut</span> invocations {
  tools.repair_invocation(inv); <span class="c">// </span>
}
messages.push(ChatMessage::Assistant { tool_calls: invocations, .. });
<span class="k">for</span> inv <span class="k">in</span> &invocations { emit(ToolCall { id, name, input }); }`
  },
  {
    t: "并发派发工具",
    tag: "step 末 · 出口 B 续",
    h: "每个工具一个 task",
    b: "所有调用并发执行(tokio::spawn + join_all),每个走 invoke_cancellable。整体再套一层 select!:取消一旦触发,立刻回 TurnEnd{interrupt} 并返回,而 detach 的工具 task 仍能让取消感知运行时去终止远程进程。",
    code: `<span class="k">let</span> handles = invocations.map(|inv| tokio::spawn(<span class="k">async</span> {
  tools.invoke_cancellable(inv, cancel).<span class="k">await</span>
}));
<span class="k">let</span> pairs = tokio::select! {
  _ = token.cancelled() => <span class="k">return</span> emit(TurnEnd{interrupt}),
  r = join_all(handles)  => r,
};`
  },
  {
    t: "回填结果 → 下一步",
    tag: "step 末 · 出口 B 续",
    h: "把工具结果塞回历史",
    b: "按调用顺序逐一处理结果:ToolFailure(模型可见失败)包成 JSON 塞回历史;ToolRuntimeError(基础设施故障)则终止回合。成功/失败都发 ToolResult 事件、持久化,然后 continue 回到压缩检查——下一步模型就能看到工具结果。",
    code: `<span class="k">for</span> (inv, outcome) <span class="k">in</span> pairs {
  <span class="k">match</span> outcome {
    <span class="k">Err</span>(Runtime(e)) => <span class="k">return</span> emit_err(ToolRuntime(e)), <span class="c">// </span>
    _ => { messages.push(ChatMessage::Tool { content, is_error, .. });
           emit(ToolResult { id, output }); }
  }
}
<span class="c">// continue   step 02</span>`
  },
];

const steplist = document.getElementById("steplist");
const detail = document.getElementById("detail");
let cur = 0;

STEPS.forEach((s, i) => {
  const el = document.createElement("div");
  el.className = "step" + (i === 0 ? " active" : "");
  el.innerHTML = `<div class="sn">${i + 1}</div><div class="st">${s.t}</div>`;
  el.addEventListener("click", () => select(i));
  steplist.appendChild(el);
});

function render() {
  const s = STEPS[cur];
  detail.innerHTML =
    `<div class="dtag">${s.tag}</div>` +
    `<div class="dh">${s.h}</div>` +
    `<div class="db">${s.b}</div>` +
    `<pre><code>${s.code}</code></pre>` +
    `<div class="navbtns">
       <button id="prev" ${cur === 0 ? "disabled" : ""}> </button>
       <button id="next" ${cur === STEPS.length - 1 ? "disabled" : ""}> </button>
     </div>`;
  document.getElementById("prev").onclick = () => select(cur - 1);
  document.getElementById("next").onclick = () => select(cur + 1);
}
function select(i) {
  if (i < 0 || i >= STEPS.length) return;
  cur = i;
  [...steplist.children].forEach((c, j) => c.classList.toggle("active", j === i));
  render();
}
render();

/* ── 模块地图表 ─────────────────────────── */
const MODS = [
  ["model.rs", "模型客户端:OpenAI / Anthropic 流式实现 + 桩", 3238],
  ["agent_loop.rs", "回合循环编排:重试 / 重连 / 取消 / 派发", 2811],
  ["shell_risk.rs", "bash 命令风险分级", 2216],
  ["mcp.rs", "MCP 客户端 + Composite 工具运行时", 1985],
  ["tools/mod.rs", "ToolRuntime trait、规格、glob/edit 工具函数", 1396],
  ["tool_repair.rs", "截断 JSON 修复 + 按 schema 修入参", 1358],
  ["tools/e2b/mod.rs", "E2b 沙箱运行时(Connect → envd)", 886],
  ["compaction.rs", "上下文压缩策略 + token 估算", 704],
  ["tools/local.rs", "本机工具运行时实现", 630],
  ["tools/sandbox.rs", "通用 SandboxExecutor trait + 运行时", 437],
  ["skills.rs", "技能加载与提示词渲染(渐进披露)", 341],
  ["history_sanitize.rs", "历史净化(去除不合法消息序列)", 298],
  ["tools/e2b/connect.rs", "E2b Connect 协议编解码", 238],
  ["tools/bounded.rs", "BoundedToolRuntime 装饰器(落盘 + 修复)", 221],
  ["event.rs", "事件 / 用量 / 错误 / TurnInput 领域类型", 196],
  ["runner.rs", "NativeHarness trait + 测试假实现", 177],
  ["context/jsonl.rs", "JSONL 上下文读写(load/append/rewrite)", 98],
  ["lib.rs", "公共 API 重导出", 56],
  ["tools/approval.rs", "审批门:Yolo / Plan / 自定义", 54],
];
const max = Math.max(...MODS.map(m => m[2]));
const tbody = document.querySelector("#modtable tbody");
MODS.forEach(([name, role, loc]) => {
  const tr = document.createElement("tr");
  const w = Math.max(4, Math.round(loc / max * 90));
  tr.innerHTML =
    `<td class="mono">${name}</td>` +
    `<td><span class="role">${role}</span></td>` +
    `<td style="text-align:right"><span class="bar" style="width:${w}px"></span><span class="loc">${loc.toLocaleString()}</span></td>`;
  tbody.appendChild(tr);
});

/* ── 术语 tooltip ───────────────────────── */
const TERMS = {
  turn: "一次 <code>run_turn</code> 调用。harness 在内部可能与模型往返多个 step(每次工具调用一轮),但对外是一个连续的事件流。",
  toolcall: "模型在回复中请求执行某个工具(如 <code>bash</code>、<code>read</code>)。harness 并发执行后把结果塞回历史,再让模型继续。",
  dotted: "正文里这种带虚线下划线的词都有悬停解释。",
};
const tip = document.getElementById("tip");
document.querySelectorAll(".term").forEach(el => {
  const key = el.dataset.t;
  if (!TERMS[key]) return;
  el.addEventListener("mousemove", e => {
    tip.innerHTML = TERMS[key];
    tip.classList.add("on");
    const pad = 14;
    let x = e.clientX + pad, y = e.clientY + pad;
    const r = tip.getBoundingClientRect();
    if (x + r.width > innerWidth - 8) x = e.clientX - r.width - pad;
    if (y + r.height > innerHeight - 8) y = e.clientY - r.height - pad;
    tip.style.left = x + "px";
    tip.style.top = y + "px";
  });
  el.addEventListener("mouseleave", () => tip.classList.remove("on"));
});

/* 键盘左右切换 stepper */
addEventListener("keydown", e => {
  if (e.key === "ArrowLeft") select(cur - 1);
  if (e.key === "ArrowRight") select(cur + 1);
});
</script>
</body>
</html>