<!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>
(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; }
.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.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); }
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); }
.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; }
.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 { 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; }
.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 { 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; }
#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> · v0.2.2 · 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>
<section>
<h2><span class="num">01</span>分层全景</h2>
<p class="sub">
调用方只跟 <code>AgentLoopHarness</code> 打交道。harness 把"怎么跟模型说话"和"怎么执行工具"分别委托给两个 trait,
自己只负责编排。横切关注点(压缩 / 上下文 / 事件 / 技能 / 修复 / 风险分级)各自独立成模块。
</p>
<div class="stack">
<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>
<div class="layer">
<div class="lhead"><span class="t">编排层 · AgentLoopHarness</span><span class="d">run_turn → run_loop (<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;">
<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>
<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<E></span>
<span>E2bToolRuntime</span>
<span>McpToolRuntime</span>
<span>CompositeToolRuntime</span>
<span>BoundedToolRuntime<R></span>
</div>
</div>
</div>
</div>
<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>
<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"></div>
<div class="detail" id="detail"></div>
</div>
</section>
<section>
<h2><span class="num">03</span>对外只发事件</h2>
<p class="sub">
harness 不返回结果对象,而是通过 <code>mpsc::Receiver<Result<HarnessInternalEvent, …>></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<Value, String></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>
<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<E>(任意远程沙箱)</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>
<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></tbody>
</table>
</div>
</section>
<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();
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 < 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);
});
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"));
});
addEventListener("keydown", e => {
if (e.key === "ArrowLeft") select(cur - 1);
if (e.key === "ArrowRight") select(cur + 1);
});
</script>
</body>
</html>