ACP RFC: session/suspend + session/await_resumption
Upstream repo: agentclientprotocol/agent-client-protocol
Sibling discussions: ACP #1220 — session/inject,
ACP #1224 — session/remind (covered by the sibling
session/inject_reminder RFC);
this RFC is the suspend-side companion to the already-shipped
session/resume (ACP #1726).
Status: Draft (not yet filed upstream).
Authors: Burin Labs
Reference impl: harn-vm cooperative suspend primitive
(crates/harn-vm/src/stdlib/agents.rs —
__host_worker_suspend + WorkerSuspension) and harn-serve ACP
adapter (crates/harn-serve/src/adapters/acp/mod.rs —
handle_session_resume).
Problem statement
ACP today has three session lifecycle verbs:
session/new— open a fresh session.session/load— cold-replay a persisted session, re-emitting every historicalsession/updatenotification so the client can rebuild its view.session/resume(shipped in ACP #1726) — silent state restore: the agent picks up where it left off without replaying any history.
There is no companion session/suspend verb. A client that wants
to pause an in-flight session today has three workarounds, all bad:
session/cancel— tears the turn down, leaves no resumable handle, drops scheduled tool calls. Wrong semantics: cancellation is "I no longer want this work," not "pause and come back."- Close the transport and reopen later — works for
session/loadcold replay, but loses the warm in-process worker (provider sessions, prompt cache, attached tool handles). Forces the agent to redo work the spec explicitly addedsession/resumeto avoid. - Out-of-band signalling — send a synthetic user message ("STOP") and hope the model emits no further tool calls. Non-deterministic and pollutes the durable transcript with control strings.
There is also no first-class verb for agent-initiated self-park.
The closest existing verb is session/request_permission (the agent
blocks until the host approves a specific action), but that's
permission-shaped — it carries a RequestPermissionParams payload,
the host must respond with allow/deny, and the session resumes
immediately on either answer. Agents that want to say "I'm done with
this turn, wake me when condition X holds (a file changes, a build
finishes, a deadline arrives)" have no shape for it.
The asymmetry is striking: ACP has session/resume but no
session/suspend; the agent can be cold-replayed via session/load
but cannot proactively park itself; mid-turn user steering exists
(session/inject from #1220, session/remind from #1224) but
mid-turn caller-initiated pauses do not.
Why this matters in practice
Concrete scenarios we hit shipping Harn:
- Long-running tool waitpoints. A Harn agent calls a tool that spawns a long CI job. The agent wants to suspend until the build finishes (could be minutes to hours), without holding the model's prompt cache open or burning autonomy budget on idle turns.
- Caller-driven pause for review. A coordinator wants to pause a delegated worker, inspect its transcript, decide whether to let it continue or hand off to another worker. Today the coordinator has to invent its own out-of-band pause channel.
- Scheduled work. "Wake me at 09:00 UTC and continue" or "wake me when this channel emits an event." The agent describes the resumption condition once and parks; the host or runtime fires the resume when the condition holds.
- Budget/cost interrupts. A policy engine wants to pause every agent that exceeds a token budget until an operator decides whether to refill.
Harn ships all four today through __host_worker_suspend (caller-
initiated) and agent_await_resumption (agent-initiated self-park),
both built on the same WorkerSuspension envelope. Both verbs are
currently tunneled through host-private JSON-RPC under
_meta.harn.suspend; the spec gap is the only thing preventing
external ACP clients from speaking the same primitive.
Proposed wire format
session/suspend (client → agent)
A new JSON-RPC method on the agent side, sibling to session/resume
shipped in #1726:
/**
* Cooperatively pause an in-flight session at the next turn boundary.
*
* Suspend is NOT cancellation — the session remains valid and a
* subsequent `session/resume` reopens it warm (or `session/load`
* cold-replays it). Pending tool calls that have already been
* dispatched complete; the agent commits to no further turns until
* resumed.
*
* Mirrors mode semantics from `session/inject` (ACP #1220): the
* default is to commit at the next turn boundary; `interrupt_immediate`
* is a stronger guarantee for callers that need to halt mid-turn.
*/
export interface SessionSuspendRequest {
/** Target session. */
sessionId: SessionId
/** Free-form reason recorded on the persisted suspension envelope. */
reason?: string
/**
* Optional resume conditions the agent should satisfy before being
* woken. When omitted, only explicit `session/resume` can wake the
* session.
*/
resumeWhen?: ResumeConditions
/**
* Delivery mode. Mirrors `session/inject` semantics.
* - "finish_step" (default): commit at the next turn boundary.
* - "interrupt_immediate": suspend before the next tool call dispatches.
* - "wait_for_completion": let the in-flight turn fully resolve.
*/
mode?: "interrupt_immediate" | "finish_step" | "wait_for_completion"
/** Extension envelope for protocol-extension fields. */
_meta?: Record<string, unknown>
}
export interface SessionSuspendResponse {
/** Server-assigned handle the client must echo on `session/resume`. */
handle: string
/** Why the session is parked. */
reason?: string
/** Wall-clock time the suspension was committed (ISO-8601). */
suspendedAt: string
/** Resume conditions the agent will honor (echoed back to confirm). */
resumeWhen?: ResumeConditions
/**
* Optional digest the agent recommends the client surface to the user
* (e.g. "paused on long-running build; ETA 3m"). Plain prose, no
* required structure.
*/
summary?: string
_meta?: Record<string, unknown>
}
export interface ResumeConditions {
/** Resume on a named event (e.g. "ci.passed", "file.changed:src/lib.rs"). */
onEvent?: string
/** Resume on a trigger-spec match (host-defined event matcher). */
trigger?: Record<string, unknown>
/** Resume after a deadline elapses; action describes the fallback. */
timeout?: ResumeTimeoutSpec
}
export interface ResumeTimeoutSpec {
/** Wake-up deadline in minutes from suspend commit. */
durationMinutes: number
/** What to do on timeout if the explicit conditions never fire. */
onTimeout?: "resume_with_summary" | "fail" | "resume_with_input"
}
session/await_resumption (agent → client)
An agent-initiated dual to session/suspend. Lets the agent declare
"I have nothing more to do until X" without the client having to know
when to call session/suspend:
/**
* Agent-initiated self-park. The agent commits no further turns
* until the supplied conditions are met or the client explicitly
* resumes via `session/resume`.
*
* Distinct from `session/request_permission`, which is a synchronous
* allow/deny gate for a specific action. await_resumption is
* asynchronous and condition-driven.
*/
export interface SessionAwaitResumptionRequest {
sessionId: SessionId
/** Free-form reason. */
reason: string
/** Conditions for the host to satisfy before resuming. */
conditions?: ResumeConditions
/**
* Optional summary the client should surface in its UI while the
* agent is parked.
*/
summary?: string
_meta?: Record<string, unknown>
}
export interface SessionAwaitResumptionResponse {
/** Same handle shape as session/suspend; same resume entrypoint. */
handle: string
suspendedAt: string
_meta?: Record<string, unknown>
}
Resume path enrichment
session/resume (already shipped in #1726) gains two optional fields
without breaking existing callers:
export interface SessionResumeRequest {
/** Existing field from #1726. */
sessionId: SessionId
/**
* Optional value to feed back to the agent's resume waitpoint.
* Mirrors `resume_with_input` semantics in our reference impl.
*/
input?: unknown
/**
* When true (default), the resumed turn sees the full pre-suspend
* transcript. When false, the agent starts a fresh turn with a
* pre-suspend digest reminder injected (mirrors our
* `continue_transcript: false` mode).
*/
continueTranscript?: boolean
_meta?: Record<string, unknown>
}
Lifecycle notification: session/update → suspended / resumed
Two new discriminators on the existing session/update union so
observers (notebooks, transcript browsers) can render suspended state
without polling:
export interface SessionUpdate_Suspended {
sessionUpdate: "suspended"
handle: string
reason?: string
initiator: "client" | "agent"
suspendedAt: string
resumeWhen?: ResumeConditions
_meta?: Record<string, unknown>
}
export interface SessionUpdate_Resumed {
sessionUpdate: "resumed"
handle: string
/** Why the agent was woken. */
cause: "explicit_resume" | "condition_fired" | "timeout" | "external_event"
/** True iff a value was fed back via `input`. */
hadResumeInput: boolean
/** Mirrors the `continueTranscript` field on the resume request. */
continueTranscript: boolean
resumedAt: string
_meta?: Record<string, unknown>
}
Capability negotiation
Agents that support suspend declare it during initialize:
export interface AgentCapabilities {
// ...existing fields...
/** Agent accepts `session/suspend`. */
supportsSuspend?: boolean
/** Agent emits `session/await_resumption`. */
supportsAwaitResumption?: boolean
/** Resume modes the agent can honor. Absent means [\"explicit_resume\"]. */
resumeCauses?: ("explicit_resume" | "condition_fired" | "timeout" | "external_event")[]
}
Clients that observe an agent without supportsSuspend should fall
back to session/cancel (with the documented caveat that the work is
lost) or close the transport and use session/load for a cold replay
on the next connection.
Compatibility and migration
From the current _meta.harn.suspend envelope
Harn ships today with:
__host_worker_suspend(worker, reason, options?)builtin (seecrates/harn-vm/src/stdlib/agents.rs) — the runtime- level cooperative suspend primitive.agent_await_resumption(reason, conditions?)script-facing builtin for agent-initiated self-park.WorkerSuspensionJSON envelope persisted to.harn-runs/and exchanged across the host boundary under_meta.harn.suspend.*on the existingsession/updatenotification surface.session/resumeACP method (already standardized via #1726, handled byhandle_session_resumeincrates/harn-serve/src/adapters/acp/mod.rs).
Migration steps once the upstream schema lands:
- Add
session/suspendandsession/await_resumptionas siblings of the existingsession/resumehandler inharn-serve; mark the_meta.harn.suspendJSON-RPC tunnel deprecated in the_metaextension contract. - Promote
WorkerSuspensionenvelope fields (handle,reason,resume_when,suspended_at) from_meta.harn.suspend.*to top-level standardized fields onSessionSuspendResponse. - Emit
SessionUpdate::SuspendedandSessionUpdate::Resumedfrom the existing lifecycle event sinks; drop_meta.harn.suspenddecoration onsession/updatenotifications once consumers migrate. - Regenerate
spec/protocol-artifacts/viamake gen-protocol-artifacts.
No-op for older clients: session/cancel-as-fallback continues to
work, and the _meta.harn.suspend envelope stays available for at
least one ACP minor version after the standardized fields ship.
For other ACP agents adopting this proposal
Agents that don't currently have a cooperative suspend primitive can
implement session/suspend as a thin wrapper that:
- Cancels any in-flight tool calls (or lets them complete in
wait_for_completionmode). - Persists the session's last known prompt + transcript pointer.
- Returns a handle the agent can re-open on
session/resume.
That's strictly stronger than the session/cancel-and-cold-replay
workaround and requires no transcript schema work. Adding
session/await_resumption is optional and only needed by agents that
want to self-park.
Reference implementation status
| Surface | Status | Notes |
|---|---|---|
__host_worker_suspend Rust builtin | Shipping (v0.8.x) | crates/harn-vm/src/stdlib/agents.rs — cooperative suspend at the next turn boundary. |
agent_await_resumption script builtin | Shipping (v0.8.x) | crates/harn-stdlib/src/stdlib/agent/workers.harn — exposes the agent-initiated dual. |
WorkerSuspension JSON envelope | Shipping | Round-trips suspend handle, reason, resume conditions, pipeline + parent worker linkage. |
ResumeConditions validator (parse_resume_conditions) | Shipping | Validates trigger / timeout / on_event shape; backs the proposed ResumeConditions schema field-for-field. |
session/resume ACP method | Shipping (via ACP #1726) | handle_session_resume in crates/harn-serve/src/adapters/acp/mod.rs. |
session/suspend ACP method | Reference impl tracked under this issue (harn#1848) | Will tunnel under _meta.harn.suspend until upstream lands. |
session/await_resumption ACP method | Reference impl tracked under this issue | Same _meta.harn.suspend tunnel; agent-initiated dispatch. |
SessionUpdate::Suspended / Resumed wire shape | Reference impl tracked under this issue | Will emit under _meta.harn.suspend until upstream lands. |
| Suspend/resume conformance suite (S-11, #1847) | Shipping | Seven paired .harn / .expected fixtures under conformance/tests/agents/ covering caller suspend, agent self-park, timeout, double-resume race, close-while-suspended. |
OTel Suspension / Resume span pairing (S-18, #1867) | Shipping | Suspend span closes before snapshot persists; resume span links (not parents) back to suspend + pipeline span at suspend time. |
| Lifecycle replay determinism receipts (P-08, #1861) | Shipping | SuspensionReceipt, ResumptionReceipt, DrainDecisionReceipt with HMAC-signed timestamps round-trip across record/replay. |
The canonical WorkerSuspension lifecycle envelope (verbatim from
crates/harn-vm/src/stdlib/agents_workers/mod.rs) maps field-for-
field onto the proposed wire format:
pub struct WorkerSuspension {
pub handle: String,
pub reason: Option<String>,
pub resume_when: Option<ResumeConditions>,
pub initiator: SuspensionInitiator, // Client | Agent
pub suspended_at_ms: i64,
pub pipeline_span_link: Option<SpanLink>,
pub prior_span_link: Option<SpanLink>,
pub parent_worker_id: Option<String>,
}
pub struct ResumeConditions {
pub on_event: Option<String>,
pub trigger: Option<serde_json::Value>,
pub timeout: Option<ResumeTimeoutSpec>,
}
pub struct ResumeTimeoutSpec {
pub duration_minutes: i64,
pub on_timeout: Option<ResumeTimeoutAction>, // resume_with_summary | fail | resume_with_input
}
Open questions for upstream maintainers
- Naming.
session/suspendmirrors the existingsession/resume. An alternative issession/pause, which avoids the connotation that suspend is a stronger / colder shape than resume's twin. We've usedsuspendto match the verb already in common use in Temporal, Restate, and Azure Durable Functions for the same primitive. - Initiator discriminator. We've split caller-initiated
(
session/suspend) from agent-initiated (session/await_resumption) into two methods because the request payloads differ enough (mode + sessionId vs reason + conditions) that overloading one method would force optional fields whose validity depends on the caller direction. Maintainers may prefer a singlesession/suspendwith aninitiatorfield; we'd accept either. modesemantics. Shouldsession/suspendhonor the sameinterrupt_immediate/finish_step/wait_for_completiondelivery modes assession/inject, or always behave asfinish_step? Our reference impl defaults tofinish_step(the safest shape) and exposesinterrupt_immediatefor the panic-broadcastInterruptAndSuspendtrigger handler variant (#1910) used for cost / budget interrupts.resumeWhenshape. We propose three fields (onEvent,trigger,timeout). Maintainers may prefer a single opaqueConditionsvalue the agent is free to parse, leaving the schema to host extension. We've found the typed shape essential for replay determinism — agents that round-trip a condition need a stable schema for hashing.continueTranscriptsemantics. Defaulting totruepreservessession/resume's current behavior (#1726 doesn't mention transcript scoping because cold-replay always replays the full thing). Defaulting tofalsematches the "fresh turn with a digest" pattern most production agents want. We've defaulted totrueto preserve back-compat with #1726, but the right default for the spec long-term may befalse.SessionUpdate::Suspended/Resumedgranularity. Are these in scope for the first iteration, or should the spec ship only the methods and leave lifecycle notifications for a follow-up? Our experience with reminder lifecycle (see the sibling reminder RFC) says notebook / transcript UIs need at least suspend / resume visibility to render coherent timelines.- Relationship to A2A
TaskState.PAUSED. We've filed a parallel A2A RFC for an explicitPAUSEDtask state distinct fromINPUT_REQUIRED/AUTH_REQUIRED. Thecauseenum onSessionUpdate::Resumeddeliberately mirrors the A2A pause-cause taxonomy so cross-protocol bridges (Harn'sharn-serveadapter today) can round-trip causes verbatim. If ACP's verb shape diverges substantially from A2A's, the cross-protocol story gets noisier.
References
- ACP #1726 —
session/resume(already shipped) - ACP #1220 —
session/inject(mode semantics borrowed) - ACP #1224 —
session/remind(sibling lifecycle RFC) - Sibling A2A RFC:
TaskState.PAUSED - Sibling ACP RFC:
session/inject_reminder - Harn ACP/MCP extensions v1
__host_worker_suspendbuiltinhandle_session_resumeACP handler