Sleeper agent
Pause an agent loop until an external task completes, with no busy-waiting inside the loop body.
An agent triggers a CI pipeline deploy via a tool call. The pipeline takes
an unknown amount of time: could be seconds, could be minutes. Rather
than blocking a model turn or polling inline, the recipe forks a
dedicated polling fiber that repeatedly checks pipeline status and
resolves a Deferred when a terminal state is reached. The main agent
loop awaits that Deferred at the top of the next iteration.
What it shows
- Using
Deferredto coordinate a main agent fiber with a background polling fiber. The deferred is a one-shot signal: set once by the poller, awaited once by the agent. forkPipelinePolleras a self-contained primitive: create theDeferred, fork the polling fiber into an explicit scope withEffect.forkIn, return theDeferredfor the caller to await.Deferred.intoso the awaiter is always released: success, check failure, or interruption all complete theDeferred.- A side-channel
Queuethat bridges the tool’srunfunction (which forks the poller) with the loop body (which drains the pendingDeferreds at the top of the next turn). A queue keeps every poller even when the model triggers several deploys in one turn. - Polling with
Effect.repeat+Schedule.spaced, stopping on a predicate via theuntiloption, withSchema.isderiving the terminal-state guard from a singleSchema.Literalssource of truth.
The fork-and-return pattern
export const forkPipelinePoller = ( pipelineId: string, checkStatus: CheckStatus, scope: Scope.Scope, interval: Duration.Input = "2 seconds",) => Effect.gen(function* () { const signal = yield* Deferred.make<PipelineResult, PipelineCheckError>() yield* Effect.forkIn(pollPipeline(pipelineId, checkStatus, signal, interval), scope) return signal })The caller gets back a Deferred it can await at any point. The polling
fiber is forked into the scope passed as an explicit value. When that
scope closes, the poller is interrupted too. No leaked fibers. The poll
effect ends in Deferred.into(signal), so whether the pipeline reaches a
terminal state or checkStatus fails, the Deferred is completed and
the awaiter is never left hanging.
Coordination inside the loop
loop((state) => Effect.gen(function* () { // Drain (non-blocking) any pipelines forked in prior turns and block // on each before the next turn. `Queue.clear` returns immediately with // an empty array when nothing is pending; `takeAll` would block. const signals = yield* Queue.clear(pending) const messages = yield* Effect.forEach( signals, (signal) => Deferred.await(signal).pipe( Effect.map((r) => Items.userText(`Pipeline ${r.pipelineId} completed with status: ${r.status}`), ), Effect.catch((e) => Effect.succeed( Items.userText(`Pipeline ${e.pipelineId} status check failed (${e._tag})`), ), ), ), { concurrency: "unbounded" }, ) const history = [...state.history, ...messages]
// ... normal model turn }),)When the queue is empty the loop runs a model turn immediately. When a
pipeline is pending it blocks until the polling fiber resolves the
Deferred. No provider call is open, no HTTP connection is held. Tool
results are folded back into history with
Toolkit.continueWithResults(Toolkit.appendToolResults({ history }, turn)),
the same pattern the other tool-using recipes share.
Deferred vs Latch
The pause-resume recipe uses a Latch for
open/close gating. A Deferred is the right choice here because the
signal is one-shot: the pipeline finishes exactly once. A Latch
can be opened and closed repeatedly, overkill for a single completion
event, and it doesn’t carry a result value.
The full source lives next to this README at
index.ts.