From 70cecc6ba100601473b7dd84add581113217387c Mon Sep 17 00:00:00 2001 From: Kit Langton <7587245+kitlangton@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:21:34 +0000 Subject: [PATCH] docs(core): prototype plugin session architecture --- specs/v2/plugin-session-cycle.prototype.ts | 200 +++++++++++++++++++++ specs/v2/plugin-session-tools-plan.md | 124 +++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 specs/v2/plugin-session-cycle.prototype.ts create mode 100644 specs/v2/plugin-session-tools-plan.md diff --git a/specs/v2/plugin-session-cycle.prototype.ts b/specs/v2/plugin-session-cycle.prototype.ts new file mode 100644 index 0000000000..a07329e93c --- /dev/null +++ b/specs/v2/plugin-session-cycle.prototype.ts @@ -0,0 +1,200 @@ +/** + * Prototype: plugin Session API dependency patterns. + * + * Run from repo root with: + * + * bun specs/v2/plugin-session-cycle.prototype.ts + * + * The first case recreates the stripped-down cycle. The second case is the first + * working version: PluginService stays in the location runtime, but it no longer + * constructs PluginHost. A global PluginSupervisor constructs the host after it + * can see both Session and a concrete location runtime. + */ + +type NodeName = + | "App" + | "Session" + | "LocationSession" + | "LocationServiceMap" + | "LocationRuntime" + | "InstanceState" + | "PluginService" + | "PluginHost" + | "PluginInternal" + | "PluginSupervisor" + | "SDK" + | "ToolDomain" + +type Graph = Readonly> + +const empty: Graph = { + App: [], + Session: [], + LocationSession: [], + LocationServiceMap: [], + LocationRuntime: [], + InstanceState: [], + PluginService: [], + PluginHost: [], + PluginInternal: [], + PluginSupervisor: [], + SDK: [], + ToolDomain: [], +} + +/** + * RED: current shape if ctx.session is added to the host PluginService builds. + * + * Walkthrough: + * - App needs Session and LocationServiceMap. + * - Session needs LocationServiceMap for APIs like prompt/revert that route to a location. + * - LocationServiceMap builds LocationRuntime. + * - LocationRuntime builds PluginService. + * - PluginService builds PluginHost. + * - PluginHost now wants Session for ctx.session. + */ +const currentPluginOwnsHost: Graph = { + ...empty, + App: ["Session", "LocationServiceMap"], + Session: ["LocationServiceMap"], + LocationServiceMap: ["LocationRuntime"], + LocationRuntime: ["PluginService", "PluginInternal", "ToolDomain"], + PluginService: ["PluginHost"], + PluginHost: ["Session", "ToolDomain"], + PluginInternal: ["PluginService"], +} + +/** + * GREEN v1: host construction moves out of PluginService. + * + * PluginService remains per-location, but is only lifecycle/scope ownership. + * PluginSupervisor is app/global. It loads configured plugins for a location by: + * - reading Session for ctx.session + * - asking LocationServiceMap for that location runtime + * - building PluginHost from those concrete capabilities + * - asking the location PluginService to own the plugin scope + * + * LocationRuntime no longer needs PluginHost or PluginInternal while it is being + * constructed, so Session can route through LocationServiceMap without looping + * back into Session. + */ +const supervisorOwnsHost: Graph = { + ...empty, + App: ["Session", "LocationServiceMap", "PluginSupervisor"], + Session: ["LocationServiceMap"], + LocationServiceMap: ["LocationRuntime"], + LocationRuntime: ["PluginService", "ToolDomain"], + PluginService: [], + PluginSupervisor: ["Session", "LocationServiceMap", "PluginHost", "PluginService"], + PluginHost: ["Session", "ToolDomain"], +} + +/** + * GREEN v2: split location-sensitive functions into a location service. + * + * Session is global data: create/get/list/messages/etc. It does not route into + * LocationServiceMap. LocationSession is built inside the location runtime and + * owns prompt/revert/other operations that touch location services. + * + * PluginHost can combine Session + LocationSession into one ctx.session surface, + * while the graph still obeys: location services may depend on global services, + * but global services do not depend on location services. + */ +const locationSessionOperations: Graph = { + ...empty, + App: ["Session", "LocationServiceMap"], + LocationServiceMap: ["LocationRuntime"], + LocationRuntime: ["PluginService", "PluginHost", "LocationSession", "ToolDomain"], + PluginService: [], + PluginHost: ["Session", "LocationSession", "ToolDomain"], + LocationSession: ["Session", "ToolDomain"], +} + +/** + * GREEN but cursed: all services are global and read the active location from + * InstanceState. This removes LocationServiceMap from the construction graph. + * + * The cost is that location correctness becomes ambient: every operation must + * trust that InstanceState currently points at the session's location, or add + * runtime assertions to catch a wrong ambient location. + */ +const allGlobalInstanceState: Graph = { + ...empty, + App: ["InstanceState", "Session", "PluginService", "ToolDomain"], + Session: ["InstanceState"], + PluginService: ["PluginHost"], + PluginHost: ["Session", "ToolDomain", "InstanceState"], + ToolDomain: ["InstanceState"], +} + +/** + * GREEN v3: SDK as one plugin instance. + * + * The SDK does not install a location-specific plugin or write a global tool + * registry. It receives the same PluginHost context as any plugin instance, and + * its methods are wrappers over that host. When plugins are booted per location, + * this SDK-backed plugin instance contributes normal location-local transforms. + */ +const sdkAsPluginInstance: Graph = { + ...empty, + App: ["Session", "LocationServiceMap"], + LocationServiceMap: ["LocationRuntime"], + LocationRuntime: ["PluginService", "PluginHost", "LocationSession", "ToolDomain", "SDK"], + PluginService: [], + PluginHost: ["Session", "LocationSession", "ToolDomain"], + SDK: ["PluginHost"], + LocationSession: ["Session", "ToolDomain"], +} + +expectCycle("current PluginService owns PluginHost with ctx.session", currentPluginOwnsHost) +assertAcyclic("supervisor owns PluginHost; PluginService is lifecycle only", supervisorOwnsHost) +assertAcyclic("location Session operations; globals do not route down", locationSessionOperations) +assertAcyclic("all-global services with InstanceState ambient location", allGlobalInstanceState) +assertAcyclic("SDK is a plugin instance that calls PluginHost", sdkAsPluginInstance) + +function expectCycle(name: string, graph: Graph) { + const cycle = findCycle(graph) + if (!cycle) throw new Error(`expected red but got green: ${name}`) + console.log(`red as expected: ${name}`) + console.log(`cycle: ${cycle.join(" -> ")}`) +} + +function assertAcyclic(name: string, graph: Graph) { + const cycle = findCycle(graph) + if (!cycle) { + console.log(`green: ${name}`) + return + } + throw new Error(`red: ${name}\ncycle: ${cycle.join(" -> ")}`) +} + +function findCycle(graph: Graph) { + const visiting = new Set() + const visited = new Set() + const stack: NodeName[] = [] + + const visit = (node: NodeName): NodeName[] | undefined => { + if (visiting.has(node)) return [...stack.slice(stack.indexOf(node)), node] + if (visited.has(node)) return + + visiting.add(node) + stack.push(node) + + for (const next of graph[node]) { + const cycle = visit(next) + if (cycle) return cycle + } + + stack.pop() + visiting.delete(node) + visited.add(node) + return + } + + for (const node of Object.keys(graph) as NodeName[]) { + const cycle = visit(node) + if (cycle) return cycle + } + + return +} diff --git a/specs/v2/plugin-session-tools-plan.md b/specs/v2/plugin-session-tools-plan.md new file mode 100644 index 0000000000..3551bc55e1 --- /dev/null +++ b/specs/v2/plugin-session-tools-plan.md @@ -0,0 +1,124 @@ +# Plugin Session and Tool Architecture Prototype + +## Goal + +Let V2 plugins access a normal session API and define tools through plugin transforms without introducing application-vs-location tools or a `Session -> LocationServiceMap -> Plugin -> Session` construction cycle. + +## Recommended direction + +Keep location-scoped services. Split location-specific session behavior out of the global session data service. + +- `SessionV2.Service` remains global and owns session data APIs: + - `create` + - `get` + - `list` + - `messages` + - `message` + - `context` + - `events` + - `history` + - metadata-only updates such as `rename`, `switchAgent`, `switchModel` +- Add a location-scoped session runtime service for behavior that touches location services: + - `prompt` + - `resume` + - `wait` + - `interrupt` + - `active` + - `revert.stage` + - `revert.clear` + - any future runner/filesystem/snapshot-coupled session operation + +This keeps the dependency rule simple: + +```txt +global services do not call location services +location services may call global services +``` + +## Why + +The current V2 shape becomes cyclic if `PluginHost` gets full `ctx.session` while `SessionV2.Service` depends on `LocationServiceMap`: + +```txt +SessionV2 +-> LocationServiceMap +-> LocationRuntime +-> PluginService +-> PluginHost +-> SessionV2 +``` + +The prototype in `plugin-session-cycle.prototype.ts` recreates this red case and compares green alternatives. + +## Ideal plugin call site + +Plugin authors should not see the split. The host composes global session data plus location session runtime into one `ctx.session` API: + +```ts +export const Plugin = define({ + id: "subagent", + effect: Effect.fn(function* (ctx) { + yield* ctx.tool.transform((draft) => { + draft.set("subagent", tool({ + description, + input: Input, + output: Output, + execute: Effect.fn(function* (input, call) { + const parent = yield* ctx.session.get(call.sessionID) + const child = yield* ctx.session.create({ + parentID: parent.id, + title: input.description, + agent: input.agent, + }) + + yield* ctx.session.prompt({ + sessionID: child.id, + prompt: { text: input.prompt }, + resume: false, + }) + yield* ctx.session.resume(child.id) + yield* ctx.session.wait(child.id) + + return { + sessionID: child.id, + status: "completed", + output: yield* ctx.session.finalText(child.id), + } + }), + })) + }) + }), +}) +``` + +## Tool model + +Tools stay location-scoped. There should not be public application tools or global tools. + +Plugins boot per location, and tools are contributed through transforms: + +```ts +yield* ctx.tool.transform((draft) => { + draft.set("repo_summary", tool({ description, input, output, execute })) +}) +``` + +The SDK should be implemented as one plugin instance: it receives a plugin host/context internally, and SDK methods call the host methods. Therefore an SDK-registered tool is just a plugin tool transform applied as each location boots. + +## Concrete implementation slices + +1. Add `packages/core/src/session/runtime.ts` as a location node. +2. Move `prompt`, `resume`, `wait`, `interrupt`, `active`, and location-sensitive `revert` operations from `SessionV2.Service` into the runtime service. +3. Update server route handlers to route location-sensitive requests at the API boundary by resolving the session location and providing that location runtime. +4. Add `ctx.session` to `PluginHost` by composing `SessionV2.Service` and the location session runtime. +5. Add public plugin `ctx.tool.transform` types and adapt it to the existing canonical core `Tool.make` representation. +6. Convert `ToolRegistry` registration to transform/rebuild semantics. +7. Port `subagent` to a built-in plugin that registers a normal location tool. +8. Remove `ApplicationTools` once no built-in tool requires process-global registration. + +## Invariants to preserve + +- A tool materialization snapshots executable tool identity; stale calls fail. +- Tool output bounding remains centralized in `ToolRegistry.Materialization.settle`. +- Location session runtime asserts that the target session belongs to the current location before running location-sensitive operations. +- Public HTTP/SDK API shape does not need to change.