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 intconst ( CancelImmediate CancelMode = 0 // 收到信号立即取消,不等安全点 CancelAfterChatModel CancelMode = 1 << iota // 等当前 ChatModel 调用结束 CancelAfterToolCalls // 等当前这轮工具调用结束)这三档对应图里的两类安全点 SafePoint(adk/turn_loop.go:1056):AfterChatModel 和 AfterToolCalls,以及”两者皆可”的 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.internalInterruptedgen.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/Set 见 adk/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 字节写进 bridgeStore 的 bridgeCheckpointID 槽位;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)。 - 双层 checkpoint 靠
bridgeStore(adk/interrupt.go:325)+ 固定 keybridgeCheckpointID桥接:内层 compose 写入,TurnLoop.finalizeCheckpoint(adk/turn_loop.go:1921)拉回拼装。
下一章我们看这套”中断能穿透边界”的能力如何成就一个精巧的抽象:AgentTool——让一个完整的 Agent 伪装成一个普通工具,却把控制流牢牢隔离。