Agent Objects, Registry, and Lifecycle
Creation, resumption, inboxes, state, and shutdown
Conclusion: an Agent is a scoped live execution capability, not the session record itself
A Session holds durable, replayable facts. An Agent is the current process's driver, Inbox, scope, and cancellation boundary around that Session. An AgentHandle is the teardown capability held only by an owner.
These objects are not interchangeable. A Session can exist without a live Agent. The same persisted Session can become a fresh Agent in a later process lifecycle. Finding an Agent in the registry proves that it is currently published, not that the observer may shut it down.
1. The interface package deliberately knows nothing about the concrete loop
@deepseek-ai/dsh-agent defines the Agent interface, registry, Inbox, initiator scope, and the full agent/* live-event vocabulary. dsh-agent-loop supplies concrete construction and driving through a single AgentFactory slot. On every create/resume, the registry retraces the factory through the caller's context so lifecycle effects belong to the fiber that actually initiated the operation. AGENT-FACTORY-SEAM
The UI, ACP bridge, subagent providers, and tests can depend only on ctx.agents without importing ReactLoopAgent. Replacing the loop requires implementing the same Agent/Factory lifecycle contract, but consumers do not need to change. This is the practical meaning of replaceability at the core-object layer.
2. Exact anatomy of an Agent
| Member | Semantics | Durable? |
|---|---|---|
id | The exact same SessionId as session.id, not a separate Agent UUID | Identity comes from the Session |
options | Provider, model, and optional positive maxTokens for this instance | Not intrinsic history; resume may select a new route |
session | The append-only event log and source of truth for derived history | Yes |
inbox | A pending-work projection reconstructed from durable splice events | Events are durable; arrays are live projections |
ctx | The Agent's Cordis scope; tools, prompt sections, and listeners unwind with it | No; rebuilt on resume |
status | Only idle or running; disposal means leaving the registry, not a third status | Live-only |
The interface also exposes send, followup, steer, inject, cancel, whenIdle, and runMaintenance. running describes the complete driver drain interval, not strictly “a turn is still open.” AGENT-PUBLIC-INTERFACE
3. The live phase is more precise than public status
ReactLoopAgent has three internal phases: idle(lastTurn), maintenance(abort, wakeRequested), and running(abort, turn, step, wakeRequested). The public layer maps both idle and maintenance to idle, and only running to running. It emits agent/status only when the mapped value changes. AGENT-RUNTIME-SCOPE
runMaintenance() synchronously acquires the truly idle phase without inventing a model turn. Waking input received during maintenance remains in the Inbox and starts a driver only after maintenance settles. whenIdle() repeatedly compares the identity of activityDone, so it follows replacement work scheduled before the observed activity retires instead of awaiting only one captured Promise.
The public state stays simple while internal scheduling retains the required precision. The cost is interpretive: an observer cannot read idle as “absolutely no background maintenance,” nor running as “the model is generating.” Turn and step events carry the precise semantics.
4. Every Agent owns a world that can be retracted as one unit
The constructor restores the last turn and Inbox from Session events, then calls createScope() with the Agent itself as the scope key and gives agent.ctx its own agent property. Tools, prompts, variables, restrictions, and listeners registered through that context are local to the Agent. AGENT-RUNTIME-SCOPE
Create/resume setup(agentCtx) runs while neither Session nor Agent is in a registry. It may asynchronously mount scoped plugins and return a synchronous commit() that revalidates mutable resources at the publication boundary. Setup composes but must not drive; throws, commit failures, and owner unload all roll back before publication. AGENT-SETUP-CONTRACT
“The Agent capability world is complete before the first model request” becomes a structural guarantee rather than a race in which an agent/created listener tries to finish installation.
5. Create and Resume converge on one atomic publication pipeline
Acquire SessionPreparation
Create validates and snapshots a fresh session's seed/meta; Resume prepares an existing Session through persistence. Neither is live in the store yet.
Prepare ReactLoopAgent
Create the scope, Inbox, and driver; fuse caller signal, owner fiber, and factory teardown before any resource exists.
Await setup
Call setup(agent.ctx) and let it finish asynchronously while registry lookups still return nothing.
Synchronous setup commit
Perform the final non-yielding validation/commit after every await and before the first publication edge.
Enter both registries
First sessions.enter(session), then agents.enter(agent, ownerCtx.agent). This is the authoritative same-ID arbitration boundary.
Announce in order
session/created → agent/created → non-vetoing agent/session-start, whose source is startup or resume.
Return AgentHandle
The creation-only signal no longer controls the live object; future lifecycle control belongs to the handle, Agent cancellation, and structural owners.
Fresh creation and persistence resume both call setupAndPublish(). Persistence loading is itself bounded by caller signal, owner unload, and factory teardown. AGENT-CREATE-RESUMEAGENT-PREPARE-PUBLISH
Tests hold async setup behind a gate and confirm that both registries remain empty, then verify the complete announcement order. Two same-ID creates may both enter setup, but exactly one publishes and the loser leaves no orphan Session. Aborting a creation signal after a handle returns does not stop the live Agent. AGENT-ATOMIC-TEST
6. Publication is split into enter and announce
enter() enforces agent.id === agent.session.id, checks ID collisions, and inserts an exact entry with a stable carrier without emitting an event. announce() may run once only for the current exact entry and marks it announced before dispatch. AGENT-REGISTRY-LIFECYCLE
The split exists for atomic publication and reentrant teardown:
- Session and Agent can first enter their stores together so listeners observe a coherent world.
- If an
agent/createdlistener synchronously requests detach, deletion waits until dispatch returns; every later listener still sees the same live entry. - The detach closure captures the entry object, not merely its ID. An old closure cannot delete a later replacement that reuses the ID.
- Rolling back an entry that was never announced does not emit
agent/disposed, avoiding an impossible lifecycle edge.
Registry tests verify split publication, idempotent detach, isolation from stale capabilities, and stable visibility to every listener when a created listener requests detach. AGENT-REGISTRY-TEST
7. Teardown has several owners but one quiescence boundary
| Authority | Why it owns the lifecycle | What it can do |
|---|---|---|
Consumer's AgentHandle | It explicitly created or resumed the Agent | Call memoized dispose() |
| Caller fiber | Structured concurrency: child resources cannot outlive the caller plugin | Unload triggers the same disposal |
| AgentLoop factory fiber | Live Agents depend on that provider implementation and service surface | Provider HMR/unload stops every old instance |
All owners race on one memoized Promise: cancel the driver with {kind:'disposed'}, await whenIdle(), dispose the Agent scope, precisely detach the Agent and then the Session, and finally release factory/owner bookkeeping. AGENT-PREPARE-PUBLISH
Even when a session/created or agent/created listener immediately unloads the owner, other listeners still see both registries intact. The observable rollback order is scope disposal, then agent/disposed if it had been announced, and finally session/disposed. AGENT-TEARDOWN-TEST
8. Runtime owner, durable lineage, and initiator are three different relations
| Relation | Meaning | Storage | Authorization? |
|---|---|---|---|
Session parentSession | Durable fork/derivation lineage | Session header | Does not automatically authorize current-process operations |
Registry owner | Which live Agent's scoped context created this live entry | Process-local AgentEntry | Supports only exact runtime-ownership queries |
| Current initiator | Which driver async chain causally triggered this work | AsyncLocalStorage | Explicitly not proof of liveness or authorization |
A persisted fork with parentSession may therefore become a runtime root when resumed independently. Conversely, a child created by a current owner cannot carry ambient initiator identity through a worker, HTTP request, process, or durable queue without explicit materialization and validation.
withInitiator() and withoutInitiator() isolate and restore identity across concurrent async chains while preserving the exact returned value or Promise. Service teardown rejects new boundaries, drains already returned Promise boundaries, then disables AsyncLocalStorage. A nested chain that starts its own owner unload is released from the drain to prevent self-wait. AGENT-INITIATOR-APIAGENT-INITIATOR-DRAIN
9. Inbox is an incremental projection of durable events
Constructing an Inbox replays every agent/inbox/spliced event after seedLength. Invalid coordinates or duplicate identities fail recovery instead of being silently repaired. The Inbox has next-turn and next-step lists, but MessageId must be unique across their union. AGENT-INBOX-REPLAYAGENT-INBOX-COMMIT
| Operation | Durable fact | Live notification |
|---|---|---|
| append / prepend | agent/inbox/spliced with inserted messages | Per-message agent/inbox/inserted |
| replace | One splice removes the old value and inserts the new one | Discard old, then insert new |
| remove / clear | Deletion splice with outcome:'canceled' | Per-message discarded |
| claim | Pure deletion splice, not canceled | Per-message claimed with the owning turn |
The implementation calls session.append() before mutating the memory array. A synchronous session observer therefore sees the pre-splice projection and can reconstruct removed messages from normalized coordinates. One step claim takes every pending next-step item first, then one next-turn item when a turn boundary requires it.
Tests cover invalid persisted splices, replacement across both lists, duplicate MessageId rejection, and clear writing two canceled events in next-step then next-turn order. AGENT-INBOX-TEST
10. Follow-up, Steer, Inject, and Cancel are not synonyms
| API | Target | Wakes? | Earliest consumption boundary |
|---|---|---|---|
followup(message) | next-turn | Yes | A new ordinary turn; one next-turn message at a time |
steer(message) | next-step | Yes | The next step of a running Agent; opens a turn when idle |
inject(message) | next-step | No | The next pre-step of an existing driver; parks while idle |
cancel(cause) | Clears both by default | No | Aborts current activity; idle cancellation does not arm future work |
cancel(cause,{keepInbox:true}) | Preserves both | No | Aborts only current activity; pending work awaits a later wake |
send() first persists Inbox insertion and then drives according to the wake flag. If the current activity is already aborted, new waking input is reclassified to next-turn so it cannot join the converging failed turn. A disposal cause never latches a new wake. AGENT-RUNTIME-SCOPE
11. Benefits, costs, and limitations
| Choice | Benefit | Cost or limitation |
|---|---|---|
| Separate Agent interface and loop packages | Consumers and orchestration do not bind to one driver | A replacement must reproduce a sophisticated lifecycle and event contract |
| Unpublished setup transaction | The first observer always sees a complete scoped world | Setup is trusted same-process code; “compose, never drive” is contractual |
| Two-stage enter / announce | Coherent dual registries and reentrant teardown | More complex lifecycle code; partially delivered notifications still require paired disposal |
| Handle as teardown capability | Read-only registry observers cannot stop an Agent | Every transport/provider must carry ownership explicitly |
| Durable Inbox projection | Idle injection, cancellation, and resume share one source of truth | Every queue mutation adds events; one MessageId carries exactly one source |
| Process-local initiator | Convenient, concurrency-isolated causal attribution in one process | Cannot cross workers/processes/wires and must never replace explicit authorization |
DeepSeek Harness does not make Agent a “smart object” that owns every state. It separates durable facts, the live driver, scoped contributions, queued work, ownership capability, and causal attribution. The decomposition is a strong foundation for resume, per-Agent plugins, HMR, and multiple transports. The real cost is that the lifecycle protocol itself resembles a small transaction system; a new provider that bypasses prepare/publish/dispose ordering will break consistency.
Chapter verification checklist
- Separated the Agent interface package from the concrete AgentLoop factory.
- Verified id/session, options, scope, status, and public input/cancellation methods.
- Traced create and resume from preparation through setup, commit, and all three publication events.
- Verified same-ID arbitration, creation-only signals, and failure rollback.
- Traced enter/announce, reentrant detach, and stale-capability protection.
- Verified shared teardown ownership across consumer, caller fiber, and factory fiber.
- Separated durable lineage, runtime owner, and process-local initiator.
- Verified Inbox replay, commit order, claims, cancellation, and MessageId uniqueness.
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.