docs(core): prototype plugin session architecture
This commit is contained in:
parent
04c6bed240
commit
70cecc6ba1
2 changed files with 324 additions and 0 deletions
200
specs/v2/plugin-session-cycle.prototype.ts
Normal file
200
specs/v2/plugin-session-cycle.prototype.ts
Normal file
|
|
@ -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<Record<NodeName, readonly NodeName[]>>
|
||||
|
||||
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<NodeName>()
|
||||
const visited = new Set<NodeName>()
|
||||
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
|
||||
}
|
||||
124
specs/v2/plugin-session-tools-plan.md
Normal file
124
specs/v2/plugin-session-tools-plan.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue