-
Notifications
You must be signed in to change notification settings - Fork 2
docs(agents): session model spec — the source-of-truth contract #2337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| # Agent Sessions — the model and the source-of-truth contract | ||
|
|
||
| Status: normative. This is Phase 0 of the **Agent-Session Single Source of Truth** epic. | ||
| The model in §1–§2 is shipped (PRs #2258, #2336). The contract in §3 is the epic's target | ||
| state, being landed phase by phase; each divergence is marked "today X / target Y". | ||
|
|
||
| The whole document compresses to one rule: **every fact has one owner, every write emits, | ||
| every subscriber can prove it is current.** If a feature needs a second copy of a fact, | ||
| it derives at read time or it doesn't ship. | ||
|
|
||
| ## 1. The model | ||
|
|
||
| ``` | ||
| Drive (or null = global assistant) | ||
| └─ Session / Workspace agent_sessions row — owns ONE Sprite sandbox | ||
| ├─ conversation w/ Agent A conversations row, sessionId FK | ||
| ├─ conversation w/ Agent B (many agents, one filesystem) | ||
| └─ shell-1, shell-2 agent_session_shells rows — PTYs in the same sandbox | ||
| ``` | ||
|
|
||
| A **session** (the `agent_sessions` table; canonically a *workspace*, see §4) is a working | ||
| context: a drive-level environment that owns one Sprite sandbox and hosts many | ||
| conversations plus any number of shells. The environment is primary; what runs inside it | ||
| lives inside it. | ||
|
|
||
| Shipped invariants (source: `packages/db/src/schema/agent-sessions.ts`, | ||
| `packages/db/src/schema/conversations.ts`): | ||
|
|
||
| - **A session is NOT a conversation.** The first cut made `conversationId` the primary | ||
| key and folded the Sprite name from it — a cardinality error that forced one | ||
| environment per thread. PR #2258 inverted the association: `agent_sessions.id` is its | ||
| own cuid, and `conversations.sessionId` FKs it. The Sprite key | ||
| (`deriveAgentSessionSpriteKey`) folds the session id, so every conversation and shell | ||
| in a session resolves the same sandbox **by construction** — no shared id is threaded | ||
| anywhere. | ||
| - **Binding is write-once.** `conversations.sessionId` is set at creation, or — for a | ||
| conversation that has never had one — by exactly one guarded claim of the caller's own | ||
| row (`conversationRepository.claimConversation`, `WHERE sessionId IS NULL AND userId = | ||
| :caller`; `apps/web/src/lib/agent-sessions/claim-conversation-in-session.ts`). No | ||
| UPDATE path re-points a bound row: a thread's history and its filesystem always agree. | ||
| Moving a thread to another session is a **fork** (a new conversation), never a rebind. | ||
| - **History outlives compute.** The FK is ON DELETE SET NULL: deleting a session keeps | ||
| its threads as plain history. Ending a session keeps its row entirely — `end` stamps | ||
| `teardownRequestedAt` / `spriteTornDownAt` / `endedAt` and deletes nothing. | ||
| - **Ended sessions are revivable** (PR #2336). A claim or create landing in an ended | ||
| session withdraws the end-intent (`planSessionReopen()` clears `endedAt` only; the | ||
| confirmed-kill stamp `spriteTornDownAt` survives, so provisioning fresh-creates). | ||
| There is no `session_ended` refusal anywhere. A reopened row is re-endable. | ||
| - **Global assistant = null `driveId`.** A global-assistant session lives outside any | ||
| drive; access and billing fall back to `ownerId` — owner-only **by construction** | ||
| (`packages/lib/src/agent-sessions/decide-session-access.ts`: a null-drive session has | ||
| no drive to share through). Drive sessions authorize by drive access | ||
| (owner/accepted member); unknown denies. | ||
| - **No `agentPageId` on the session.** A session hosts conversations with many agents, | ||
| so the agent association lives on each conversation (`conversations.contextId`), | ||
| never on the session. | ||
| - **Ids address, names label.** `name` carries no uniqueness constraint and nothing | ||
| looks a session up by it. (Shell names are unique per session for tab titles only; | ||
| lookups always go through `id`.) | ||
| - **`closedInSessionAt` ≠ `isActive`.** Closing a conversation out of its session's | ||
| listing (`closedInSessionAt`) never touches history soft-delete (`isActive`). | ||
| Reopening clears the stamp. Closed listings are refused by worker verbs (§2). | ||
| - **Sandbox status is derived, never stored** (`deriveSandboxStatus`, | ||
| `packages/lib/src/services/agent-sessions/session-status.ts`): the four lifecycle | ||
| stamps are each single-writer facts; a status column would be a second copy. This | ||
| stays true under the epic — it is the pattern, not an exception to it. | ||
| - **Pane grid**: today `agent_sessions.workspaceState` is one jsonb blob with exactly | ||
| one write path — the client's debounced sync (`useWorkspaceServerSync` → | ||
| `PUT /api/agent-sessions/{id}/workspace` → `saveSessionWorkspace`). Server tool | ||
| paths cannot write placement at all: `open_page_pane` only acks and relies on a | ||
| client-side reaction (`useOpenPagePane`), and worker spawn placement mints | ||
| conversations without touching the grid — so server-side actions are invisible to | ||
| any grid no client happens to be rendering, and the client's localStorage copy is a | ||
| third version of the same fact. / Target: relational pane tables + rev counter + | ||
| idempotent verbs behind one shared writer that the client, the server, and the AI | ||
| tool paths all post through, with the blob and the localStorage copy dying at | ||
| contract (epic Phase 3, the #2202 machine-panes pattern). | ||
|
|
||
| ## 2. Authorization axioms (PR #2336 — product-locked) | ||
|
|
||
| These are product decisions, not implementation accidents. They supersede issue #2262 | ||
| finding 1's workspace confinement. | ||
|
|
||
| 1. **Verbs are resource-addressed and permission-gated, like `read_page`.** | ||
| `send_session` / `read_session` / `kill_session` authorize against the resource: | ||
| the caller owns the worker conversation, it is actually a worker (bound into some | ||
| workspace), and its listing is not human-closed. A resource the caller does **not** | ||
| own always reads as nonexistent — anti-enumeration, today's behavior and kept. | ||
| For the caller's *own* rows, today every failure cause collapses into the same | ||
| not-yours message / Phase 1 target: distinct, typed, actionable refusals | ||
| (closed-listing vs not-yet-a-worker), per the "Tool contract pin and typed | ||
| refusals" task — the collapse is an implementation state, not an axiom. The | ||
| calling conversation plays no authorization role and is not required. (Page-worker dispatch additionally | ||
| re-enforces the agent's RBAC inside the standard chat pipeline it runs through.) | ||
| 2. **Binding state, lifecycle state, and calling surface NEVER refuse a permitted | ||
| operation.** Ended sessions reopen on use. Unbound threads mint a workspace | ||
| permission-gated (global with the user's own authority; page conversations behind | ||
| `canUserViewPage` + `checkAccessForSubject`, the manual spawn route's exact | ||
| primitives). A thread having no session yet is a state to resolve, not an error. | ||
| 3. **The global assistant is the user's own authority, from any surface** — dashboard, | ||
| sidebar, panes, agents page. Page AI is its drive's RBAC. Neither gains or loses | ||
| power by where the request was typed. | ||
| 4. **Location's only job is defaults and pane placement.** The calling conversation | ||
| supplies the default workspace for a spawn and decides where a pane opens — nothing | ||
| else. | ||
| 5. **Cross-workspace orchestration is legitimate.** `spawn_session` takes `workspace` | ||
| (omitted = caller's own, minted if needed; `'new'` = fresh isolated workspace; | ||
| an id = spawn straight into it, gated by session access). `list_sessions` lists all | ||
| the caller's workspaces, every worker everywhere addressable by the verbs. The | ||
| advisory cap pre-count applies only to own-workspace spawns — a full caller | ||
| workspace can't refuse a spawn aimed somewhere with room. | ||
|
|
||
| Unchanged by the re-model: the conversation→session binding stays write-once and | ||
| owner-only (the hijack surface stays closed); shells stay workspace-scoped | ||
| (`spawn_shell` / `send_shell` / `read_shell` / `kill_shell` act only on the caller's | ||
| own session's sandbox — a foreign shell reads as nonexistent). | ||
|
|
||
| ## 3. The source-of-truth contract | ||
|
|
||
| > **Status: target state.** This section is being landed by the Agent-Session Single | ||
| > Source of Truth epic, phase by phase. Where today's code diverges, the divergence is | ||
| > named. Until a phase lands, the code is what ships; this section is where it is going. | ||
|
|
||
| Four clauses: | ||
|
|
||
| 1. **Every fact has one server-side owner.** One writer per table, behind a repository | ||
| choke point (message writes converge on a message repository; conversation | ||
| lifecycle on the conversation repository). Routes never decide whether to | ||
| broadcast. *Today:* ~10 call sites save messages directly and membership facts are | ||
| stored three ways (FK, `workspaceState` jsonb, localStorage). *Target:* epic | ||
| Phases 2–3. | ||
| 2. **Every owner emits on write.** Each committed write broadcasts a rev-carrying event | ||
| (`conversations.rev` bumped in-transaction; the event carries the post-write rev). | ||
| *Today:* nothing broadcasts on message persistence — only the stream lifecycle | ||
| emits, and the `chat:user_message` broadcast is gated on `isShared`, so a user's own | ||
| server-side dispatch is invisible to their own open panes. *Target:* epic Phase 2. | ||
| 3. **Every surface is a subscriber that can prove it is current.** Pane, sidebar, and | ||
| agent alike hold a rev watermark; an event with `rev == watermark + 1` applies, a | ||
| gap triggers a snapshot refetch, reconnect runs a batched rev check. Transport stays | ||
| best-effort; correctness comes from rev + refetch, not delivery guarantees. | ||
| *Today:* caches apply events only for conversations already loaded and lists heal by | ||
| 15–20s polls. *Target:* epic Phases 2–4; polls demoted to backstops. | ||
| 4. **A tool action and a UI action are indistinguishable to every observer — including | ||
| the acting agent.** A server-side `send_session` dispatch and a pane's own POST take | ||
| the same write path, emit the same events, and appear live in the same surfaces. | ||
| Read-your-writes holds for agents too: an agent that just wrote through a tool can | ||
| immediately observe its own write through any read path. | ||
|
|
||
| The acceptance criterion, one sentence: **if a feature needs a second copy of a fact, it | ||
| derives at read time or it doesn't ship.** Forced copies get drift-guards; dual-writes | ||
| get one shared writer. | ||
|
|
||
| ## 4. Vocabulary | ||
|
|
||
| "sessionId" has carried five meanings. The canonical names: | ||
|
|
||
| | Canonical name | What it is | Where "sessionId" meant this | | ||
| |---|---|---| | ||
| | `workspaceId` | An `agent_sessions` row — the working context / sandbox owner | Everywhere except the tool layer: `conversations.sessionId`, `agent_session_shells.sessionId`, `/api/agent-sessions/[sessionId]`, `?session=` URLs | | ||
| | `conversationId` | A thread (`conversations` row) | The session-tool layer: the `sessionId` param of `send_session` / `read_session` / `kill_session` is a **worker's conversation id** (`apps/web/src/lib/ai/tools/session-tools.ts`, where `workspaceSessionId` is the workspace) | | ||
| | *(frozen)* `sessionId` | The model-facing tool param | The wire vocabulary is deliberately frozen at the zod boundary: to the model, a "session" is a worker you talk to and a "workspace" is the environment. Internal renames never touch these schemas | | ||
| | `spriteExecId` | The Sprite PTY exec stream a shell reattaches under | `agent_session_shells.streamSessionId` (rename lands in the epic's final phase) | | ||
| | — | Auth login sessions (`sessions` table, `packages/db/src/schema/sessions.ts`) | Unrelated. Never mix with any of the above | | ||
|
|
||
| Also nearby but distinct: `ai_stream_sessions` (a background streaming *run* of one chat | ||
| turn) is a run record, not an address in any of the five senses. | ||
|
|
||
| The epic's final phase renames the DB/module/route layer to match (`agent_sessions` → | ||
| `agent_workspaces`, `conversations.sessionId` → `workspaceId`, etc., with one-release | ||
| compat shims). Until then, this table is the decoder. | ||
|
|
||
| ## 5. Keeping this honest | ||
|
|
||
| Tool guidance has lied to the model before — this is a recurring bug class, not a | ||
| hypothetical. The `session-tools.ts` module header documented a cross-workspace | ||
| confinement guard for a full release after PR #2336 deleted it. A description the code | ||
| no longer enforces is worse than no description: the model plans around it. | ||
|
|
||
| The normative sources for session semantics are, in order: | ||
|
|
||
| 1. **The contract tests** *(forthcoming — epic Phase 1, task "Tool contract pin and | ||
| typed refusals")*: snapshot tests pinning the model-facing tool JSON schemas, plus | ||
| tests pinning each tool description's behavioral claims to the code that enforces | ||
| them. Once landed, a description that drifts from behavior fails CI instead of | ||
| shipping. | ||
| 2. **This document** for the model and the axioms. | ||
| 3. Schema doc comments (`agent-sessions.ts`, `conversations.ts`) for per-column | ||
| rationale. | ||
|
|
||
| A PR that changes session semantics — a verb's gates, the binding rules, lifecycle | ||
| stamps, event emission — must update the contract tests **and** this document in the | ||
| same PR. If this document and the code disagree and no test catches it, fixing the | ||
| disagreement is the first commit of whatever you were doing. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Specify a language for the diagram fence.
Line [13] opens a fenced block without a language. Add
textto satisfy markdownlint MD040 and identify the ASCII diagram format.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 13-13: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools