目录 · 第 15 / 28 章
EinoPart III · ADK 的实现

15TurnLoop、取消与中断的实现

推送式事件循环、CancelMode 安全点状态机、bridgeStore 桥接 ADK↔compose 两层 checkpoint。

adk/turn_loop.go:896adk/cancel.go:43adk/interrupt.go:325

从”拉”到”推”

第 14 章你看到 ReAct 是一张编译好的图,第 13 章你看到 flowAgent 如何驱动它。但还有一个问题没回答:当运行需要长期存活——比如一个能被随时追加输入、随时喊停、随时中断续跑的对话会话——谁来当那个”总调度”?

答案是 TurnLoop。如果说 flowAgent.run 是”一次运行”的骨架,TurnLoop 就是”多轮会话”的骨架。它把前面所有机制(事件流、取消、中断、checkpoint)拧成一个可长期运行的循环。这一章我们拆开三样东西:推送式的事件循环、取消的安全点状态机、以及连接 ADK 与底层 compose 的双层 checkpoint 桥

先记住上面这张图的两条流,后面反复用到:横向是控制流沿节点前进,纵向是同一份状态被序列化进 checkpoint、再还原。TurnLoop 的全部复杂度,都是在管好这两条流的交汇点。

TurnLoop:一个推送式事件循环

TurnLoop[T, M](adk/turn_loop.go:896)是一个双泛型结构:T 是你推给它的输入项类型,M 是消息类型。它对外只有四个动词:

// TurnLoop is a push-based event loop for agent execution.
// Users push items via Push() and the loop processes them through the agent.
//
// Create with NewTurnLoop, then start with Run:
//
// loop := NewTurnLoop(cfg)
// // pass loop to other components, push initial items, etc.
// loop.Run(ctx)
//
// # Permissive API
//
// All methods are valid on a not-yet-running loop:
// - Push: items are buffered and will be processed once Run is called.
// - Stop: sets the stopped flag; a subsequent Run will exit immediately.
// - Wait: blocks until Run is called AND the loop exits. If Run is never
// called, Wait blocks forever (this is a programming error, analogous
// to reading from a channel that nobody writes to).
type TurnLoop[T any, M MessageType] struct {
config TurnLoopConfig[T, M]
buffer *turnBuffer[T]
stopped int32
started int32
done chan struct{}
result *TurnLoopExitState[T, M]
runOnce sync.Once
stopCtrl *stopController
preemptCtrl *preemptController
runErr error
interruptedItems []T
checkPointRunnerBytes []byte
interruptContexts []*InterruptCtx
capturedCancelErr *CancelError
pendingResume *turnLoopPendingResume[T]
loadCheckpointID string
onAgentEvents func(ctx context.Context, tc *TurnContext[T, M], events *AsyncIterator[*TypedAgentEvent[M]]) error
// … 省略 5 行;完整声明 L879–931,点击上方「浏览完整文件」
func (l *TurnLoop[T, M]) Run(ctx context.Context) // 启动循环
func (l *TurnLoop[T, M]) Push(item T, opts ...PushOption[T, M]) (bool, <-chan struct{}) // 推入一项
func (l *TurnLoop[T, M]) Stop(opts ...StopOption) // 请求停止
func (l *TurnLoop[T, M]) Wait() *TurnLoopExitState[T, M] // 等待退出

Run(adk/turn_loop.go:1350)只启动一次(靠 runOnce sync.Once 保证),真正的循环体在私有的 run(adk/turn_loop.go:1570)里。Push(adk/turn_loop.go:1381)把新输入塞进内部 buffer 并返回一个 channel 让你等这一项被处理完;Stop(adk/turn_loop.go:1524)请求优雅退出;Wait(adk/turn_loop.go:1565)阻塞到 done channel 关闭。

// Run starts the loop's processing goroutine. It is non-blocking: the loop
// runs in the background and results are obtained via Wait.
//
// If CheckpointID is configured in TurnLoopConfig and a matching checkpoint
// exists in Store, the loop automatically resumes from that checkpoint.
// Otherwise it starts fresh with whatever items were Push()-ed.
//
// Calling Run more than once is a no-op: only the first call starts the loop.
func (l *TurnLoop[T, M]) Run(ctx context.Context) {
l.start(ctx)
}
func (l *TurnLoop[T, M]) run(ctx context.Context) {
defer l.cleanup(ctx)
if err := l.tryLoadCheckpoint(ctx); err != nil {
l.runErr = err
return
}
// Monitor context cancellation: close the buffer so that a blocking
// Receive() unblocks. The loop will then check ctx.Err() and exit.
go func() {
select {
case <-ctx.Done():
l.buffer.Close()
case <-l.done:
}
}()
for {
if l.stopCtrl.isCommitted() {
return
}
isResume := false
var pr *turnLoopPendingResume[T]
var items []T
var pushBack []T
if l.pendingResume != nil {
isResume = true
pr = l.pendingResume
l.pendingResume = nil
l.preemptCtrl.waitForPushes()
pr.newItems = append(pr.newItems, l.buffer.TakeAll()...)
pushBack = make([]T, 0, len(pr.interrupted)+len(pr.unhandled)+len(pr.newItems))
pushBack = append(pushBack, pr.interrupted...)
pushBack = append(pushBack, pr.unhandled...)
pushBack = append(pushBack, pr.newItems...)
} else {
var first T
var ok bool
if idleFor := l.stopCtrl.idleDuration(); idleFor > 0 {
l.buffer.ClearWakeup()
idleTimer := time.NewTimer(idleFor)
cancelIdle := make(chan struct{})
// … 省略 120 行;完整声明 L1570–1737,点击上方「浏览完整文件」
// Push adds an item to the loop's buffer for processing.
// This method is non-blocking and thread-safe.
// Returns false if the loop has stopped, true otherwise. If a preemptive push
// succeeds, the second return value is a channel that callers can wait on to
// confirm the preempt request has been resolved. Specifically:
// - If Push observes a planning or active turn that is still the target when
// the request resolves, the channel closes after TurnLoop attempts to submit
// cancel for that target turn.
// - If Push observes no target turn, the loop has not started, the preempt
// subsystem is closed, or a delayed target is already gone, the channel
// closes as a no-op resolution.
//
// If the loop has not been started yet (Run not called), items are buffered
// and will be processed once Run is called.
// After Wait() returns, failed pushes can be recovered via TurnLoopExitState.TakeLateItems().
// Once TakeLateItems() has been called, any subsequent push that would become a
// late item will panic instead of being silently dropped.
//
// Use WithPreempt() or WithPreemptTimeout() to atomically push an item and signal
// preemption of the current agent. This is useful for urgent items that should
// interrupt the current processing.
// The returned channel may be waited on if the caller needs to ensure the preempt
// signal has been observed.
//
// Use WithPreemptDelay() together with WithPreempt()/WithPreemptTimeout() to delay
// request resolution. Push returns immediately after the item is buffered, and
// the delayed request remains bound to the turn observed by Push.
func (l *TurnLoop[T, M]) Push(item T, opts ...PushOption[T, M]) (bool, <-chan struct{}) {
cfg := &pushConfig[T, M]{}
for _, opt := range opts {
opt(cfg)
}
if cfg.pushStrategy != nil {
return l.pushWithStrategy(item, cfg)
}
return l.pushWithConfig(item, cfg)
}
// Stop signals the loop to stop and returns immediately (non-blocking).
// Without options, the current agent turn runs to completion and the loop
// exits at the turn boundary without starting a new turn. ExitReason is nil.
//
// Use WithImmediate() to abort the running agent turn immediately.
// Use WithGraceful() to cancel at the nearest safe point with recursive
// propagation to nested agents.
// Use WithGracefulTimeout() for safe-point cancel with an escalation deadline.
// Use UntilIdleFor() to defer the stop until the loop has been continuously
// idle for a given duration; the loop shuts down automatically once the idle
// timer fires.
//
// This method may be called multiple times; subsequent calls update cancel options.
// A Stop() call without UntilIdleFor shuts down the loop immediately, even if
// a prior UntilIdleFor is still waiting.
// Call Wait() to block until the loop has fully exited and get the result.
//
// Stop may be called before Run. In that case, the stopped flag is set and
// a subsequent Run will exit the loop immediately.
//
// If the running agent does not support the WithCancel AgentRunOption,
// all cancel-related options (WithImmediate, WithGraceful, WithGracefulTimeout)
// degrade to "exit the loop on entering the next iteration" — the current
// agent turn runs to completion before the loop exits.
func (l *TurnLoop[T, M]) Stop(opts ...StopOption) {
cfg := &stopConfig{}
for _, opt := range opts {
opt(cfg)
}
// UntilIdleFor is incompatible with cancel options (WithImmediate,
// WithGraceful, WithGracefulTimeout) in the same call. Cancel opts only
// make sense for an immediate or escalated stop; UntilIdleFor defers the
// stop until idle, and must not impact a running agent. Drop them silently.
if cfg.idleFor > 0 {
cfg.agentCancelOpts = nil
}
decision := l.stopCtrl.requestStop(cfg)
if decision.wakeIdle {
l.buffer.Wakeup()
}
if decision.commit {
l.finishStopCommit()
}
}
// Wait blocks until the loop exits and returns the result.
// This method is safe to call from multiple goroutines.
// All callers will receive the same result.
//
// Wait blocks until Run is called AND the loop exits. If Run is
// never called, Wait blocks forever.
func (l *TurnLoop[T, M]) Wait() *TurnLoopExitState[T, M] {
<-l.done
return l.result
}

📝 注意

Part I 里你用 iter.Next() 主动”拉”事件,那适合一问一答。但一个长期会话需要外部随时能推入新消息内部持续产出事件——这是典型的生产者/消费者。TurnLoop 因此内建一对异步管道:AsyncIterator(消费端 Next)与 AsyncGenerator(生产端 Send),二者由 NewAsyncIteratorPair(adk/utils.go:57)成对创建,底层是一个无界 channel(adk/utils.go:31)。推送式循环让”追加输入”和”消费事件”彻底解耦。

// NewAsyncIteratorPair returns a paired async iterator and generator
// that share the same underlying channel.
func NewAsyncIteratorPair[T any]() (*AsyncIterator[T], *AsyncGenerator[T]) {
ch := internal.NewUnboundedChan[T]()
return &AsyncIterator[T]{ch}, &AsyncGenerator[T]{ch}
}
type AsyncIterator[T any] struct {
ch *internal.UnboundedChan[T]
}

循环每处理一项,就调用 runAgentAndHandleEvents(adk/turn_loop.go:1809)去跑一轮 Agent。这里有个精巧的设计:它并不直接把 Agent 的事件流交给外层,而是先建一个 bridgeStore(adk/turn_loop.go:1820)、用它构造一个内层 TypedRunner(adk/turn_loop.go:1836),再新开一对代理管道(adk/turn_loop.go:1877),用一个 goroutine 把内层事件逐条 Send 转发到外层(adk/turn_loop.go:1896)。为什么要套这一层?下一节的 checkpoint 桥会揭晓。

func (l *TurnLoop[T, M]) runAgentAndHandleEvents(
ctx context.Context,
agent TypedAgent[M],
spec *turnRunSpec[T, M],
) error {
l.interruptContexts = nil
l.capturedCancelErr = nil
l.checkPointRunnerBytes = nil
var iter *AsyncIterator[*TypedAgentEvent[M]]
runOpts, ms, err := l.setupBridgeStore(spec, spec.runOpts)
if err != nil {
l.preemptCtrl.abortPlanningTurn().ack()
return err
}
store := l.config.Store
cancelOpt, agentCancelFunc := WithCancel()
runOpts = append(runOpts, cancelOpt)
// For Run path the streaming mode comes from the input. For Resume path the
// runner reads the streaming mode persisted in the checkpoint, so the value we
// pass here is irrelevant.
enableStreaming := false
if spec.input != nil {
enableStreaming = spec.input.EnableStreaming
}
runner := NewTypedRunner(TypedRunnerConfig[M]{
EnableStreaming: enableStreaming,
Agent: agent,
CheckPointStore: ms,
})
preemptDone := make(chan struct{})
stoppedDone := make(chan struct{})
tc := &TurnContext[T, M]{
Loop: l,
Consumed: spec.consumed,
Preempted: preemptDone,
Stopped: stoppedDone,
StopCause: l.stopCtrl.cause,
}
l.preemptCtrl.beginActiveTurn(ctx, tc)
l.stopCtrl.beginActiveTurn()
defer func() {
l.stopCtrl.endActiveTurn()
l.preemptCtrl.endActiveTurn().ack()
// … 省略 121 行;完整声明 L1809–1977,点击上方「浏览完整文件」

取消:一台安全点状态机

第 14 章说过,取消不是 kill,而是”跑到安全点再优雅停下”。这一章我们看它的实现。核心是 CancelMode(adk/cancel.go:43),它枚举了三个”停在哪”的策略:

// CancelMode specifies when an agent should be canceled.
// Modes can be combined with bitwise OR to cancel at multiple safe-points.
// For example, CancelAfterChatModel | CancelAfterToolCalls cancels the agent
// after whichever safe-point is reached first.
type CancelMode int
const (
CancelImmediate CancelMode = 0 // 收到信号立即取消,不等安全点
CancelAfterChatModel CancelMode = 1 << iota // 等当前 ChatModel 调用结束
CancelAfterToolCalls // 等当前这轮工具调用结束
)

这三档对应图里的两类安全点 SafePoint(adk/turn_loop.go:1056):AfterChatModelAfterToolCalls,以及”两者皆可”的 AnySafePoint。你通过 WithCancel()(adk/cancel.go:217)拿到一个取消函数,调用它就触发 triggerCancel(adk/cancel.go:423)。

// SafePoint describes at which boundary the agent may be cancelled.
// It is a bitmask: values can be combined with bitwise OR to accept multiple
// safe points (e.g. AfterToolCalls | AfterChatModel). Internally, SafePoint
// is translated to CancelMode via toCancelMode().
//
// SafePoint is used only in the preemption API (WithPreempt/WithPreemptTimeout).
// A key design constraint: preemption always targets a safe point — the user's
// intent is to cancel at a well-defined boundary, never to abort immediately.
// Immediate cancellation is only reachable as an automatic timeout escalation
// (via WithPreemptTimeout), not as a direct user choice. This is why SafePoint
// has no "immediate" value and why WithPreempt requires a non-zero SafePoint
// (panics otherwise).
type SafePoint int
// WithCancel creates an AgentRunOption that enables cancellation for an agent run.
// It returns the option to pass to Run/Resume and a cancel function.
// Cancel options (mode, timeout) are passed to the returned AgentCancelFunc at call time.
func WithCancel() (AgentRunOption, AgentCancelFunc) {
cc := newCancelContext()
opt := WrapImplSpecificOptFn(func(o *options) {
o.cancelCtx = cc
})
cancelFn := cc.buildCancelFunc()
return opt, cancelFn
}
func (cc *cancelContext) triggerCancel(mode CancelMode) {
cc.setMode(mode)
if atomic.CompareAndSwapInt32(&cc.state, stateRunning, stateCancelling) {
close(cc.cancelChan)
}
}

真正判断”现在该不该停”的,是一个原子状态机 cancelContext(adk/cancel.go:301)。它的 mode 字段用原子操作读写,shouldCancel()(adk/cancel.go:466)和 isImmediateCancelled()(adk/cancel.go:482)在每个安全点被查询,决定是继续还是收尾。

type cancelContext struct {
mode int32 // atomic, CancelMode
cancelChan chan struct{} // closed when cancel is requested (all modes, not just safe-point)
immediateChan chan struct{} // closed when an immediate graph interrupt fires
doneChan chan struct{} // closed when execution completes (by any mark* method)
doneOnce sync.Once // ensures doneChan is closed exactly once
state int32 // stateRunning, stateCancelling, stateDone, stateCancelHandled
interruptSent int32 // interruptNotSent, interruptImmediate
escalated int32 // 1 if escalated from safe-point to immediate
timeoutEscalated int32 // 1 if escalation was triggered by timeout
startedMode int32 // atomic, mode when state transitioned to cancelling
deadlineUnixNano int64 // atomic, 0 means no deadline
recursive int32 // atomic; 1 if cancel should propagate into AgentTool internal agents
recursiveChan chan struct{} // closed when recursive transitions from 0 to 1
root bool // true for the original cancelContext created by WithCancel(); false for AgentTool internal agents
parent *cancelContext // non-nil for AgentTool internal agents; used to propagate AgentTool boundary markers upward
agentToolDescendant int32 // atomic; 1 once an AgentTool runs under this cancel context
cancelMu sync.Mutex
timeoutOnce sync.Once
timeoutNotify chan struct{}
mu sync.Mutex
graphInterruptFuncs []func(...compose.GraphInterruptOption)
}
// shouldCancel returns true if a cancel has been requested (cancelChan is closed).
func (cc *cancelContext) shouldCancel() bool {
if cc == nil {
return false
}
select {
case <-cc.cancelChan:
return true
default:
return false
}
}
// isImmediateCancelled returns true if an immediate graph interrupt has been
// fired (CancelImmediate or timeout escalation). This is stronger than
// shouldCancel: it means the compose graph is being torn down right now and
// orphaned goroutines should not attempt to send events.
func (cc *cancelContext) isImmediateCancelled() bool {
if cc == nil {
return false
}
select {
case <-cc.immediateChan:
return true
default:
return false
}
}

🔑 本章的设计钥匙

取消被实现成一台安全点状态机,而不是一次硬中断。这样做的深层原因是:取消和中断共用同一套底盘。看 wrapIterWithCancelCtx(adk/cancel.go:806)这个包装器——它拦截事件流,当发现根 Agent 在安全点抛出了一个”内部中断信号”、且此刻确实该取消时,就把这个中断就地翻译成一个 CancelError 往外送(adk/cancel.go:820)。换句话说:取消 = 在安全点主动触发一次中断,再把中断结果解读成”已取消”。第 14 章那句”取消天然和中断续跑共用机制”,在这里落到了实处——同一个安全点,既是中断的落点,也是取消的落点。

// wrapIterWithCancelCtx wraps an iterator with cancel lifecycle management.
// It calls markDone when the inner iterator is fully drained, ensuring the
// cancelContext's doneChan is closed and propagation goroutines can exit.
//
// For root cancelContexts (created by WithCancel, not deriveAgentToolCancelContext), it also
// converts interrupt ACTION events to CancelError when cancel is active.
// This is the single point of interrupt-to-CancelError conversion in the
// system — Runner.handleIter only enriches the resulting CancelError with
// checkpoint metadata.
//
// Interrupt absorption: ALL interrupts are converted when cancel is active,
// including business interrupts (compose.Interrupt from user code). Cancel and
// business interrupts cannot be reliably distinguished in concurrent execution
// (parallel workflows, concurrent tool calls) where they merge into a single
// composite signal. The business interrupt data is preserved in the checkpoint
// and re-fires naturally on resume.
//
// This conversion MUST happen in this wrapper (not deferred to Runner.handleIter)
// because markDone runs as a defer in this goroutine — if the interrupt event
// were passed through unconverted, markDone would transition stateCancelling→stateDone
// before the Runner goroutine could call createAndMarkCancelHandled, causing it
// to fail the CAS.
func wrapIterWithCancelCtx[M MessageType](iter *AsyncIterator[*TypedAgentEvent[M]], cancelCtx *cancelContext) *AsyncIterator[*TypedAgentEvent[M]] {
if cancelCtx == nil {
return iter
}
it, gen := NewAsyncIteratorPair[*TypedAgentEvent[M]]()
go func() {
defer cancelCtx.markDone()
defer gen.Close()
for {
event, ok := iter.Next()
if !ok {
break
}
if cancelCtx.isRoot() && event.Action != nil && event.Action.internalInterrupted != nil {
if cancelCtx.shouldCancel() {
cancelErr, ok := cancelCtx.createAndMarkCancelHandled()
if ok {
cancelErr.interruptSignal = event.Action.internalInterrupted
gen.Send(&TypedAgentEvent[M]{Err: cancelErr})
}
return
}
}
gen.Send(event)
// … 省略 4 行;完整声明 L784–835,点击上方「浏览完整文件」

中断的家族与序列化

取消复用的那套”中断”,本身是一个有层次的家族。最基础的是 Interrupt(adk/interrupt.go:88),它产生一个带中断动作的事件;StatefulInterrupt(adk/interrupt.go:123)在此之上额外携带一份可恢复的状态;泛型版是 TypedInterrupt(adk/interrupt.go:59)。

// Interrupt creates a basic interrupt action.
// This is used when an agent needs to pause its execution to request external input or intervention,
// but does not need to save any internal state to be restored upon resumption.
// The `info` parameter is user-facing data that describes the reason for the interrupt.
func Interrupt(ctx context.Context, info any) *AgentEvent {
return TypedInterrupt[*schema.Message](ctx, info)
}
// StatefulInterrupt creates an interrupt action that also saves the agent's internal state.
// This is used when an agent has internal state that must be restored for it to continue correctly.
// The `info` parameter is user-facing data describing the interrupt.
// The `state` parameter is the agent's internal state object, which will be serialized and stored.
func StatefulInterrupt(ctx context.Context, info any, state any) *AgentEvent {
return TypedStatefulInterrupt[*schema.Message](ctx, info, state)
}
// TypedInterrupt creates a typed interrupt event that pauses execution to request external input.
// It is the generic counterpart of Interrupt; see Interrupt for full documentation.
func TypedInterrupt[M MessageType](ctx context.Context, info any) *TypedAgentEvent[M] {
var rp []RunStep
rCtx := getRunCtx(ctx)
if rCtx != nil {
rp = rCtx.RunPath
}
is, err := core.Interrupt(ctx, info, nil, nil,
core.WithLayerPayload(rp))
if err != nil {
return &TypedAgentEvent[M]{Err: err}
}
contexts := core.ToInterruptContexts(is, allowedAddressSegmentTypes)
return &TypedAgentEvent[M]{
Action: &AgentAction{
Interrupted: &InterruptInfo{
InterruptContexts: contexts,
},
internalInterrupted: is,
},
}
}

真正让”隔离的子 Agent 也能把中断冒到顶层”的,是 CompositeInterrupt(adk/interrupt.go:156)及其泛型版 TypedCompositeInterrupt(adk/interrupt.go:129)——它能把若干个子中断信号(InterruptSignal)聚合成一个复合中断。第 16 章 AgentTool 让中断穿透工具边界,用的正是它。

// CompositeInterrupt creates an interrupt event that aggregates sub-interrupt signals.
func CompositeInterrupt(ctx context.Context, info any, state any,
subInterruptSignals ...*InterruptSignal) *AgentEvent {
return TypedCompositeInterrupt[*schema.Message](ctx, info, state, subInterruptSignals...)
}
// TypedCompositeInterrupt creates a typed interrupt event that aggregates sub-interrupt signals.
// It is the generic counterpart of CompositeInterrupt; see CompositeInterrupt for full documentation.
func TypedCompositeInterrupt[M MessageType](ctx context.Context, info any, state any,
subInterruptSignals ...*InterruptSignal) *TypedAgentEvent[M] {
var rp []RunStep
rCtx := getRunCtx(ctx)
if rCtx != nil {
rp = rCtx.RunPath
}
is, err := core.Interrupt(ctx, info, state, subInterruptSignals,
core.WithLayerPayload(rp))
if err != nil {
return &TypedAgentEvent[M]{Err: err}
}
contexts := core.ToInterruptContexts(is, allowedAddressSegmentTypes)
return &TypedAgentEvent[M]{
Action: &AgentAction{
Interrupted: &InterruptInfo{
InterruptContexts: contexts,
},
internalInterrupted: is,
},
}
}

而”续跑”需要的一切,都被打包进一个可 gob 序列化的结构 serialization(adk/interrupt.go:210):

type serialization struct {
RunCtx *runContext // 整个运行环境(第 13 章)
Info *InterruptInfo // 兼容旧版
EnableStreaming bool
InterruptID2Address map[string]Address // 中断 id → 节点地址
InterruptID2State map[string]core.InterruptState // 中断 id → 状态
}

对照 ResumeInfo(adk/interrupt.go:33):恢复时读出这份结构,按 InterruptID2Address 找回”停在哪个节点”,按 InterruptID2State 还原”当时的局部状态”。这就是可视化里那条纵向流的数据本体。

// ResumeInfo holds all the information necessary to resume an interrupted agent execution.
// It is created by the framework and passed to an agent's Resume method.
type ResumeInfo struct {
// EnableStreaming indicates whether the original execution was in streaming mode.
EnableStreaming bool
// Deprecated: use InterruptContexts from the embedded InterruptInfo for user-facing details,
// and GetInterruptState for internal state retrieval.
*InterruptInfo
WasInterrupted bool
InterruptState any
IsResumeTarget bool
ResumeData any
}

双层 checkpoint:bridgeStore 的桥

现在回答前面留的悬念:为什么 TurnLoop 要在中间套一个 bridgeStore(adk/interrupt.go:325)?

type bridgeStore struct {
mu sync.Mutex
data map[string][]byte
}

因为 ADK 的中断有两层。底层的 compose 图引擎(第 23 章)有它自己的 checkpoint 机制,当 ReAct 图在某个节点中断,是 compose 引擎先把图的状态写进一个 store。但 ADK 这一层还想在外面再包一轮会话级的状态。两层各写各的,谁来拼接?

答案就是 bridgeStore——一个内存版的 CheckPointStore(Get/Setadk/interrupt.go:330 附近),它约定了一个固定的桥接 key:

func (m *bridgeStore) Get(_ context.Context, key string) ([]byte, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if v, ok := m.data[key]; ok {
return v, true, nil
}
return nil, false, nil
}
const bridgeCheckpointID = "adk_react_mock_key"

流程是这样的:内层 compose/runner 中断时,把图的 checkpoint 字节写进 bridgeStorebridgeCheckpointID 槽位;TurnLoop 随后通过 finalizeCheckpoint(adk/turn_loop.go:1921)把这段字节从 bridgeStore拉回来,拼进自己的会话级 checkpoint:

finalizeCheckpoint := func() error {
if store != nil && ms != nil {
data, ok, err := ms.Get(ctx, bridgeCheckpointID) // 从 bridgeStore 取回底层 checkpoint
// ...
if ok {
l.checkPointRunnerBytes = append([]byte{}, data...) // 拼进 TurnLoop 的状态
}
}
return nil
}

恢复时反向:用 newResumeBridgeStore(adk/interrupt.go:319)把之前存下的字节重新塞回桥的槽位,底层引擎再从这里读回图状态。newBridgeStore(adk/interrupt.go:315)则用于全新一轮。

func newResumeBridgeStore(checkPointID string, data []byte) *bridgeStore {
return &bridgeStore{
data: map[string][]byte{checkPointID: data},
}
}
func newBridgeStore() *bridgeStore {
return &bridgeStore{data: make(map[string][]byte)}
}

📝 注意

两层用同一套底层机制(都是 CheckPointStore 接口),但语义边界不同:compose 层管的是”一张图停在哪个节点”,ADK 层管的是”整个会话停在哪一轮、还有哪些外部输入待处理”。bridgeStore 用一个固定 key 做交接,既让两层各自独立演进,又保证续跑时能严丝合缝地对接。这正是可视化里纵向流”落盘→还原”在跨层场景下的真实实现——一份状态,两层协作写入。

本章小结

  • TurnLoop(adk/turn_loop.go:896)是多轮会话的骨架:Run/Push/Stop/Wait 四个动词,内部是推送式生产者/消费者循环。
  • 推送式管道由 AsyncIterator/AsyncGenerator 成对构成(adk/utils.go:57),让”追加输入”与”消费事件”解耦。
  • 取消是一台安全点状态机:CancelMode(adk/cancel.go:43)三档策略对应 SafePoint(adk/turn_loop.go:1056),由 cancelContext(adk/cancel.go:301)原子判定。
  • 关键统一:wrapIterWithCancelCtx(adk/cancel.go:806)在安全点把中断信号翻译成 CancelError——取消与中断共用同一套底盘。
  • 中断家族:Interrupt/StatefulInterrupt/CompositeInterrupt(adk/interrupt.go:156),续跑状态打包进 serialization(adk/interrupt.go:210)。
  • 双层 checkpointbridgeStore(adk/interrupt.go:325)+ 固定 key bridgeCheckpointID 桥接:内层 compose 写入,TurnLoop.finalizeCheckpoint(adk/turn_loop.go:1921)拉回拼装。

下一章我们看这套”中断能穿透边界”的能力如何成就一个精巧的抽象:AgentTool——让一个完整的 Agent 伪装成一个普通工具,却把控制流牢牢隔离。

源码

正在读取完整文件…