From 11a074b6644736234c88c4ff21ca36edeab400b7 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 12 Jul 2026 17:17:38 +0800
Subject: [PATCH] docs(rfc): add agent-scope diagrams
---
.../2026-07-08-agent-scope-contexts.md | 38 ++++++++++++++
.../2026-07-12-agent-scope-runtime-design.md | 49 +++++++++++++++++++
2 files changed, 87 insertions(+)
diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
index 9f380e6dc1..024eb05f82 100644
--- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
+++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
@@ -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
cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"]
+ agentAContext["agentA.ctx
cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"]
+ agentBContext["agentB.ctx
cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"]
+
+ operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
eligible globals plus A local only"]
+ globalLayer --> agentAView
+ agentALayer --> agentAView
+ operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
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
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
diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
index c94f0cee63..988a30968f 100644
--- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
+++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
@@ -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