Agent 生命周期
一个 Agent 是驱动一条会话的活体:它持有待处理消息的收件箱、一个可取消的驱动循环,
以及把每次推进都写进事件日志的义务。对应实现:src/Tether.Core.Contracts/、
src/Tether.Core.Agent/、src/Tether.Core.AgentLoop/。
对外的 Agent 句柄
public interface IAgent
{
SessionId Id { get; } // 与 Session 共享的同一个身份
AgentOwnerToken Owner { get; } // 这个 exact live publication 的资源 authority
AgentOptions Options { get; } // 该 Agent 的路由与模型
ISession Session { get; }
IInbox Inbox { get; }
AgentStatus Status { get; }
ValueTask<IAgentPipelineSnapshot> AcquirePipelineAsync(CancellationToken cancellation = default);
ValueTask<string> SendAsync(string input, CancellationToken cancellation = default);
ValueTask<string> SendAsync(string input, IReadOnlyList<MessageAttachment>? attachments,
CancellationToken cancellation = default);
ValueTask<string> SendAsync(InboxMessage message, CancellationToken cancellation = default);
ValueTask FollowupAsync(InboxMessage message);
ValueTask<bool> TryFollowupWhenIdleAsync(Func<InboxMessage?> prepare, Func<ValueTask> accepted);
ValueTask SteerAsync(InboxMessage message);
ValueTask InjectAsync(InboxMessage message);
ValueTask<AgentDeliveryOutcome> DeliverAsync(InboxMessage message, AgentWakeBudget? idleWakeBudget = null);
ValueTask CancelAsync(AgentCancelCause cause, CancelOptions? options = null);
ValueTask WhenIdleAsync();
ValueTask SelectModelAsync(ModelSelection selection);
}
AgentOptions 里的 Provider、Model、MaxTokens 与 ReasoningEffort 都是可选的,只在该 Agent 想钉住某个路由时才设置。
投递方式
区别不在于消息内容,而在于什么时候被消费、以及是否唤醒驱动。
| 方法 | 进入哪条队列 | 对驱动的影响 |
|---|---|---|
SendAsync(text) | 作为其所属 turn 的唯一普通消息 | 拥有这一次发送,等到最终助手文本返回 |
FollowupAsync(msg) | NextTurn | 排队一个后续 turn 并唤醒驱动 |
SteerAsync(msg) | NextStep | 空闲时开启新 turn;运行中则在下一个 step 边界消费 |
InjectAsync(msg) | NextStep | 不唤醒驱动,等下一次 pre-step 顺带带上 |
DeliverAsync(msg, budget) | NextStep;只有拿到 AgentWakeBudget 许可且 Agent 空闲时才进 NextTurn | 原子路由:空闲时拿到许可就唤醒新 turn,否则安静进 next-step;返回实际去向 |
TryFollowupWhenIdleAsync(prepare, accepted) | NextTurn | idle maintenance 的准入式 followup:已有活动或正在提交的输入优先,不排队等待 |
SendAsync 可能返回空串
当认领到的批次被 pre-step 拒绝、或批次本身为空时,SendAsync 返回空字符串
而不是抛异常。调用方需要区分"模型没话说"与"这批输入没被接受"时应当去看
turn/end 的原因。
收件箱:两条有序队列
InboxTarget 只有两个取值,对应两种消费时机:
NextTurn("next-turn")—— 每条消息各自开启一个 turn;NextStep("next-step")—— 转向指令或注入上下文,在下一个 step 边界被消费。
public interface IInbox
{
IReadOnlyList<InboxMessage> NextTurn { get; }
IReadOnlyList<InboxMessage> NextStep { get; }
bool HasPending { get; }
ValueTask AppendAsync(InboxTarget target, InboxMessage message);
ValueTask PrependAsync(InboxTarget target, InboxMessage message);
ValueTask<bool> ReplaceAsync(string messageId, InboxMessage newMessage);
ValueTask<bool> RemoveAsync(string messageId);
ValueTask<bool> MoveToNextStepAsync(string messageId);
ValueTask ClearAsync();
ValueTask<IReadOnlyList<InboxMessage>> SpliceAsync(
InboxTarget target, int start, int deleteCount, IReadOnlyList<InboxMessage> inserted);
ValueTask<IReadOnlyList<InboxMessage>> ClaimAsync(InboxTarget target, long turn);
}
收件箱不是内存里的临时队列,而是持久事件 agent/inbox/spliced 的投影。所有变更都归一化成 splice 记录下来。
关键的顺序保证是:持久事件先提交,实时投影后变更。这样崩溃恢复重放日志得到的队列状态与运行时一致。
收件箱的全部 mutation 还要过一把串行化闸门(SemaphoreSlim):检查再行动(check-then-act)与 splice 提交是一个不可交错的原子段。
没有这道闸,两个并发写者各自读到的”当前状态”可能都过期,产生的 splice 序列在重放时就不再等价于运行期状态——这正是 issue #62 修掉的”并发 splice 导致会话日志不可重放”。
对外读取(NextTurn / NextStep)返回的是不可变快照,快照之后队列怎么变都不影响已拿到的那一份。
几个语义细节:
ReplaceAsync就地替换一条待处理消息,允许连身份一起换;返回该消息当时是否还在待处理。MoveToNextStepAsync在同一个 mutation gate 里把一条尚未认领的 queued 消息移到 next-step;只有它仍在 next-turn 时才返回 true,且不发出 discarded 通知。RemoveAsync返回该消息当时是否还在待处理,不存在不算错误。ClearAsync的清理顺序固定为先 next-step 再 next-turn。ClaimAsync取走并返回一个 step 提议的完整批次,其持久 splice 是纯删除,随后才发出认领通知;返回顺序是 next-step 输入在前、被认领的排队 turn 在后。
收件箱消息的身份
var msg = InboxMessage.FromUserText("帮我看下这个报错"); // 铸造 version 7 id
var explicitId = new InboxMessage("my-id", chatMessage, InboxMessageSource.OfKind("user"));
Id 只要求在该消息处于待处理期间唯一。构造时会立刻对用户消息做 JSON 快照,因此之后改动原对象不影响队列内容;Materialize() 从快照重建一个新的用户消息。除 FromUserText 外,InboxMessage 还有一组 From* 工厂(如 FromGoalRound、FromAgentMessage、FromWebhook),分别铸造带各自 durable source 与 provenance 的消息。
turn 与 step 的括号结构
日志里的生命周期事件是严格嵌套的括号,重建时不需要猜边界:
turn 号与 step 号都是从 1 开始的正整数,传 0 或负数会被构造函数拒绝。tool/call 记录模型给出的原样参数 JSON 与关联 id,便于精确复现。
turn 如何结束
turn/end 必须带一个 TurnEndReason,其 kind 有八种:
| kind | 含义 |
|---|---|
completed | 模型不再欠回复,自然结束。 |
aborted | 取消打断了进行中的 turn。 |
blocked | pre-step 拒绝了认领批次,一个 step 都没进入。 |
error | turn 失败。 |
max-tokens | 至少一个 step 撞到输出 token 上限。 |
interrupted | 后来的持久化修复关闭了一个孤立 turn。 |
concluded | 一个成功的工具结果(ToolExecutionOutcome.ConcludesTurn)在其 call/result 批次完整提交后终结了 turn。 |
forked | fork 切点在继承的 seed 前缀里关闭了这个 turn;活着的 turn 永远不会产生这个值,只有 fork-seed 构造会写它。 |
撞到 token 上限的 step 里,被截断的 tool call 既不派发,也不写进 assistant/message:它的参数可能不完整,永远不会有结果,留在历史里会让之后每次请求都带着一个没有结果的调用。流式片段仍然保留在 assistant/chunk 里;只剩 usage 的 assistant/message 继续承载用量,但不进入模型请求历史。
原因的构造受强约束,防止记录出自相矛盾的事实:
aborted必须带AgentCancelCause,其它 kind 带了就抛异常;error必须带LlmFailureSnapshot,其它 kind 带了就抛异常。
LlmFailureSnapshot 由诊断文本与一个与供应商无关的稳定错误码组成,这样上层重试策略不必解析各家的原始报错。
取消
await agent.CancelAsync(AgentCancelCause.User);
await agent.CancelAsync(AgentCancelCause.Hook("预算超限"), new CancelOptions { KeepInbox = true });
AgentCancelKind 有四种:user、parent、hook、disposed。构造规则很严:只有 hook 取消携带原因,hook 必须给非空原因,其它三种给了原因就抛异常。
CancelAsync 的行为:取消活动驱动,并且除非设置 KeepInbox,否则持久地取消全部待处理收件箱工作。空闲时取消是 no-op。设了 KeepInbox 时排队与转向消息被保留,但进行中的 turn 仍然被中止。
WhenIdleAsync() 在整个 Agent 的活动达到静止后返回,是测试与优雅关闭的同步点。
状态
AgentStatus 只有两个值:
idle—— 没有驱动被调度或活动;running—— 驱动正在排空、收尾或做检查点。
每次状态迁移都会镜像到 agent/status 事件上。
Agent 注册表
注册表跟踪活着的 Agent,并把创建委托给注册进来的工厂——消费者因此不必依赖具体的循环实现包。
// 循环包在启动时注册工厂;重复注册会抛异常
using var slot = registry.SetFactory(loopFactory);
// 消费者只跟注册表打交道
var handle = await registry.CreateAsync(new CreateAgentOptions(SessionId.Create())
{
AgentOptions = new AgentOptions { Model = "deepseek-flash" },
});
await handle.Agent.SendAsync("你好");
await handle.DisposeAsync();
| 成员 | 语义 |
|---|---|
SetFactory(factory) | 注册创建工厂,已有工厂时抛异常;返回清空槽位的 disposer。 |
CreateAsync(options) | 经工厂创建并发布新 Agent。 |
ResumeAsync(options) | 加载持久会话并在其上恢复 Agent。 |
RegisterAsync(agent) | 插入一个已构造好的 Agent 并公告它;返回可等待、exactly-once 的 AgentRegistration。 |
Enter(agent, owner) | 插入但不公告;返回幂等的移除闭包。 |
AnnounceAsync(agent, source, signal) | 公告先前用 Enter 插入的 Agent,发出 serial 的 agent/created 派发。 |
Get(id) | 查活着的 Agent,没有返回 null。 |
IsOwnedBy(id, owner) | 判断某个活着的子 Agent 是否由这个确切的父 Agent 创建。 |
List() / Roots() | 按注册顺序列出全部 / 全部顶层 Agent。 |
Enter 加 AnnounceAsync 的两段式存在的理由是:允许先把 Agent 放进注册表、完成准备工作,再对外宣布它可用,避免观察者看到半成品。
运行期所有权与会话血统是两件事
IsOwnedBy 判断的是运行期创建关系,与持久会话的血统无关。
一个从持久日志恢复出来的 Agent 可以没有运行期 owner,即使它的会话在历史上由别人派生。
AgentHandle.DisposeAsync() 会停止循环、等它退出、注销 Agent,再拆掉它的作用域世界。它是记忆化的:多个所有者同时竞争释放时,共享同一个静止边界,不会各自拆一遍。
运行期事件
除了写进日志的持久事件,Agent 还在共享根总线上发出运行期通知。
| 常量 | 事件名 | 参数 |
|---|---|---|
AgentCreated | agent/created | IAgent、SessionStartSource、CancellationToken(serial awaited:listener 失败使创建失败并跳过后续 listener) |
AgentDisposed | agent/disposed | IAgent |
AgentStatusChanged | agent/status | IAgent、AgentStatus |
InboxInserted | agent/inbox/inserted | IAgent、InboxMessage |
InboxClaimed | agent/inbox/claimed | IAgent、InboxMessage、turn |
InboxDiscarded | agent/inbox/discarded | IAgent、InboxMessage |
AgentTurnStarting | agent/turn-starting | IAgent、活动驱动的 CancellationToken(awaited:恰在 turn/start 落盘前发出) |
TurnStarted | agent/turn-started | 用户输入文本 |
TextDelta | agent/text-delta | 增量文本 |
ToolInvoked | agent/tool-invoked | 工具名、结果 JSON(已声明常量与参数,当前没有发出点) |
TurnCompleted | agent/turn-completed | 最终助手文本、已用轮数 |
AgentError | agent/error | IAgent、turn、step、错误 |
SessionStartSource 区分四种生命周期来源:startup(新建 Agent 与会话)、resume(驱动挂到既有日志上)、clear(会话内容被清空后重启生命周期)与 compact(会话日志被压缩进一条新的生命周期)。
两个可介入的管线点
| 常量 | 事件名 | 形态 |
|---|---|---|
AgentRequestError | agent/request-error | 模型请求失败后的恢复 waterfall |
AgentTurnStopping | agent/turn-stopping | turn 即将关闭前的 serial 终局检查点 |
agent/request-error 让插件有机会把一次失败的模型请求转成重试或降级;agent/turn-stopping 让插件在一个本已完成的 turn 关闭之前追加工作,从而把 turn 继续下去。