4.1 KiB
Agent Note: Keep one public stop primitive
Status: implemented
English | 中文
Problem
The public Agent handle exposed two overlapping ways to stop in-flight work: step-only abort() and queue-aware cancel(). The former preserved queued input while the latter originally only exposed its broad default, which clears queued and steering work while aborting the active turn. cancel(cause, { keepInbox: true }) now covers the production Web stop policy without exposing the private turn holder; ACP retains broad cancellation, while lifecycle owners tear down agents through AgentHandle.dispose(). No production caller needs a bare step-only abort.
The behavioral distinction is real, but no shipping code needs a separate narrower verb. AgentLoop owns one private cancellation holder for the whole turn. cancel(cause, options?) carries an explicit typed user or parent cause; its broad default drops pending input, while keepInbox preserves pending work for later turns. Disposal remains a separate lifecycle interruption. The complete ownership and propagation contract lives in the explicit turn cancellation note.
The extra surface area made the loop carry a public verb that was mostly a teardown internal. An options-bearing cancel() expresses caller policy without exposing a second holder-shaped operation.
Decision
cancel() is the only public stop primitive on Agent. Lifecycle owners use AgentHandle.dispose() to stop and unregister an agent; non-owners use broad cancel() to abandon current and queued work or keepInbox to abort the active turn while retaining pending work. The implementation keeps a private turn cancellation holder, but it is not part of the plugin-facing Agent contract. The Web stop decision is the production keepInbox consumer.
whenIdle() is retained as the public quiescence-observation primitive (resolve once the agent settles out of running, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop completing without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public contract (packages/acp/acp/tests, packages/core/agent-loop/tests); the production ACP bridge owns its agents and tears them down through AgentHandle.dispose(), so packages/acp/acp/src itself has no whenIdle() call.
Public abort() is absent, and the disposer remains async and waits for the loop to stop. Tests exercise cancellation through the public typed cause and explicit signal APIs rather than reaching into the holder.
Alternatives considered
Removing whenIdle() too — the original proposal's shape, reversed on validating the premise against the code: it is a load-bearing quiescence primitive that safely handles waiter settlement and replacement-turn races, and pushing consumers onto hand-observed running→idle transitions is exactly the brittle path the defensive patterns warn against.
Verification
Agent exposes no public abort() while cancel(), whenIdle(), and steer() remain; ACP cancellation calls broad cancel(), Web stop calls cancel(..., { keepInbox: true }), and teardown awaits quiescence through handle disposal. whenIdle() resolves on quiescence for non-owner observers, and the suites cover cancellation and disposal as the two supported stop paths.
Consequences
A plugin can abort the active turn while preserving queued prompts through keepInbox, but it cannot abort only one model/tool step while leaving that turn running. A step-only use case would need a named consumer and a narrower contract; exposing the private loop mechanic remains unjustified.
Related
This Agent Note only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via whenIdle(). The resulting delivery surface is followup(), steer(), and inject(); stopping and observation remain with cancel() and whenIdle().