docs(rfc): add agent-scope diagrams

This commit is contained in:
Tianyi Cui
2026-07-12 17:17:38 +08:00
parent a34801df4b
commit 11a074b664
2 changed files with 87 additions and 0 deletions
@@ -27,6 +27,24 @@ The contract has four parts:
The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations. The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations.
For scope-aware registries and default listener routing, the whole mechanism can be read from left to right: the registering context chooses a layer, while the agent named by an operation chooses which one local layer joins the deployment-global layer.
```mermaid
flowchart LR
plain["Plain plugin context<br/>cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"]
agentAContext["agentA.ctx<br/>cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"]
agentBContext["agentB.ctx<br/>cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"]
operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>eligible globals plus A local only"]
globalLayer --> agentAView
agentALayer --> agentAView
operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>eligible globals plus B local only"]
globalLayer --> agentBView
agentBLayer --> agentBView
```
The missing cross-edges describe registry resolution and default listener routing: Agent A's registered values and ordinary scoped listeners do not enter Agent B's view, and a parent's layer does not enter a child's view merely because the parent owns the child's lifetime. For scope-filtered events, `{ global: true }` is the explicit opt-in exception; it can observe across scopes while cleanup still follows the registering agent. Registry-membership notifications are a separate unfiltered event class described below.
The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal. The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal.
### Registration origin selects visibility and cleanup ### Registration origin selects visibility and cleanup
@@ -103,6 +121,26 @@ The returned promise resolves only after setup, ordered lifecycle notification,
The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime. The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime.
The lifecycle keeps the local layer private until setup succeeds and keeps it alive until final work has drained:
```mermaid
flowchart TB
request["Create or resume"] --> reserve["Reserve agent and session IDs"]
reserve --> privateWorld["Load or build private session, scope, and driver"]
privateWorld --> setup["Await setup through agent.ctx"]
setup --> publish["Publish session and agent, then start the loop"]
publish --> live["Return the live handle"]
privateWorld -->|"load or preparation failure, or owner loss"| rollback["Rollback startup<br/>no handle escapes"]
setup -->|"setup failure or owner loss"| rollback
publish -->|"publication failure or owner loss"| rollback
live -->|"handle disposal, owner unload, or AgentLoop unload"| settle["Quiesce prepared or running work"]
rollback --> settle
settle --> detach["Detach any published agent, then session"]
detach --> revoke["Dispose any created agent scope"]
revoke --> release["Release acquired IDs"]
```
Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup. Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup.
## Tool restrictions resolve against a live flat view ## Tool restrictions resolve against a live flat view
@@ -249,6 +249,37 @@ Two services split the public API from the implementation. `AgentRegistry`, reac
| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | | Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases |
| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | | Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise |
The implementation treats success, rollback, handle disposal, caller unload, and AgentLoop unload as entrances to one owned transaction rather than separate cleanup algorithms:
```mermaid
flowchart TB
caller["Caller context owner"] --> transaction["Owned create or resume transaction"]
factory["AgentLoop structural owner"] --> transaction
subgraph creation["Create or resume"]
transaction --> reserve["Reserve both IDs and install trackers"]
reserve --> prepare["Load persistence or prepare the session"]
prepare --> lifecycle["Install the complete caller-owned lifecycle"]
lifecycle --> setup["Await unpublished setup"]
setup --> enter["Enter session and agent registries"]
enter --> announce["Emit session/created, then agent/created"]
announce --> start["Enable driving, emit agent/session-start, start driver"]
end
transaction -.->|"reservation, load, or preparation failure before lifecycle handoff"| earlyRollback["Release acquired tracking and reservations"]
start --> live["Live handle"]
lifecycle -.->|"failure or owner loss before a handle escapes"| dispose["Join the lifecycle cleanup boundary"]
live -->|"dispose or either owner unloads"| dispose
subgraph teardown["Reverse-order teardown"]
dispose --> barrier["Wait for synchronous publication to unwind"]
barrier --> drain["Stop driver and complete final flushes"]
drain --> detach["Detach agent, then session"]
detach --> scope["Dispose agent scope to quiescence"]
scope --> release["Release session and agent IDs"]
end
```
The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract.
### Reservations precede awaiting; lifecycle ownership precedes setup ### Reservations precede awaiting; lifecycle ownership precedes setup
@@ -429,6 +460,24 @@ Before agent setup can run, the concrete agent pins its accepted ID, options, an
`send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol. `send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol.
The inbox path makes that accepted-value boundary concrete. Getter evaluation happens during materialization, so liveness is rechecked before the accepted record crosses into an inbox FIFO:
```mermaid
flowchart TB
callerInput["Caller-owned content and source"] --> initialCheck["Require a live, drive-enabled agent"]
initialCheck --> accept["Resolve source once; materialize and deep-freeze one record"]
accept -->|"invalid lossless JSON"| invalidReject["Throw synchronously; no inbox insertion, agent/queued, or loop wakeup"]
accept -->|"accepted"| liveness["Recheck disposal after caller getters"]
liveness -->|"disposed reentrantly"| disposedReject["Throw disposed; do not insert or announce the message"]
liveness -->|"still live"| inbox["Insert the record into the queued or steering FIFO"]
inbox -->|"same frozen content and source"| queued["Emit agent/queued with a frozen metadata wrapper"]
inbox -->|"if later drained, read the same owned record"| drain["Loop-owned delivery"]
inbox -->|"cancel before drain"| cancelled["Clear the pending record without delivery"]
inbox -->|"disposal wins before drain"| disposed["Stop delivery; the disposed agent may retain the pending record"]
drain -->|"queued prompt"| prompt["agent/prompt-submit may block or explicitly replace"]
drain -->|"steering consumed by an active turn"| steering["Append steering/message"]
```
A stateful getter shows why validation and ownership must use the same capture: A stateful getter shows why validation and ownership must use the same capture:
```js ```js