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