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

View File

@@ -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.
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.
### 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 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.
## Tool restrictions resolve against a live flat view

View File

@@ -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 |
| 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.
### 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.
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:
```js