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)NextTurnidle maintenance 的准入式 followup:已有活动或正在提交的输入优先,不排队等待

SendAsync 可能返回空串

当认领到的批次被 pre-step 拒绝、或批次本身为空时,SendAsync 返回空字符串 而不是抛异常。调用方需要区分"模型没话说"与"这批输入没被接受"时应当去看 turn/end 的原因。

收件箱:两条有序队列

InboxTarget 只有两个取值,对应两种消费时机:

  • NextTurn("next-turn")—— 每条消息各自开启一个 turn;
  • NextStep("next-step")—— 转向指令或注入上下文,在下一个 step 边界被消费。
SendAsync FollowupAsync SteerAsync InjectAsync NextTurn 每条各自开启一个 turn NextStep 转向与注入的上下文 驱动开启新 turn ClaimAsync 取走批次 下一个 step 边界 随该 step 一起消费 虚线是 InjectAsync:它同样进 NextStep,但不唤醒驱动,只等下一次 pre-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 1 turn/start { turn: 1 } request/header 重建这次请求所需的非可见面输入 step 1 step/start { turn: 1, step: 1 } assistant/message tool/call { callId, name, arguments } tool/result step/end step 2 step/start { turn: 1, step: 2 } assistant/message step/end turn/end { reason: { kind: "completed" } } 括号严格嵌套:turn 未关闭前不能开新 turn,step 未关闭前不能开新 step。
重建日志时不需要猜边界。这套嵌套约束由运行期不变量在会话包里强制校验。

turn 号与 step 号都是从 1 开始的正整数,传 0 或负数会被构造函数拒绝。tool/call 记录模型给出的原样参数 JSON 与关联 id,便于精确复现。

turn 如何结束

turn/end 必须带一个 TurnEndReason,其 kind 有八种:

kind含义
completed模型不再欠回复,自然结束。
aborted取消打断了进行中的 turn。
blockedpre-step 拒绝了认领批次,一个 step 都没进入。
errorturn 失败。
max-tokens至少一个 step 撞到输出 token 上限。
interrupted后来的持久化修复关闭了一个孤立 turn。
concluded一个成功的工具结果(ToolExecutionOutcome.ConcludesTurn)在其 call/result 批次完整提交后终结了 turn。
forkedfork 切点在继承的 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 还在共享根总线上发出运行期通知。

常量事件名参数
AgentCreatedagent/createdIAgent、SessionStartSource、CancellationToken(serial awaited:listener 失败使创建失败并跳过后续 listener)
AgentDisposedagent/disposedIAgent
AgentStatusChangedagent/statusIAgent、AgentStatus
InboxInsertedagent/inbox/insertedIAgent、InboxMessage
InboxClaimedagent/inbox/claimedIAgent、InboxMessage、turn
InboxDiscardedagent/inbox/discardedIAgent、InboxMessage
AgentTurnStartingagent/turn-startingIAgent、活动驱动的 CancellationToken(awaited:恰在 turn/start 落盘前发出)
TurnStartedagent/turn-started用户输入文本
TextDeltaagent/text-delta增量文本
ToolInvokedagent/tool-invoked工具名、结果 JSON(已声明常量与参数,当前没有发出点)
TurnCompletedagent/turn-completed最终助手文本、已用轮数
AgentErroragent/errorIAgent、turn、step、错误

SessionStartSource 区分四种生命周期来源:startup(新建 Agent 与会话)、resume(驱动挂到既有日志上)、clear(会话内容被清空后重启生命周期)与 compact(会话日志被压缩进一条新的生命周期)。

两个可介入的管线点

常量事件名形态
AgentRequestErroragent/request-error模型请求失败后的恢复 waterfall
AgentTurnStoppingagent/turn-stoppingturn 即将关闭前的 serial 终局检查点

agent/request-error 让插件有机会把一次失败的模型请求转成重试或降级;agent/turn-stopping 让插件在一个本已完成的 turn 关闭之前追加工作,从而把 turn 继续下去。

下一步

在 GitHub 上编辑此页