mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3ace428d6 | |||
| 8a819da177 | |||
| 89ef53537e | |||
| 70cecc6ba1 |
@@ -26,6 +26,7 @@ import { Reference } from "./reference"
|
|||||||
import { ReferenceGuidance } from "./reference/guidance"
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
|
import { SessionRuntime } from "./session/runtime"
|
||||||
import { SessionTodo } from "./session/todo"
|
import { SessionTodo } from "./session/todo"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { SkillGuidance } from "./skill/guidance"
|
import { SkillGuidance } from "./skill/guidance"
|
||||||
@@ -75,6 +76,7 @@ export const locationServices = LayerNode.group([
|
|||||||
BuiltInTools.node,
|
BuiltInTools.node,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
|
SessionRuntime.node,
|
||||||
SessionRunnerLLM.node,
|
SessionRunnerLLM.node,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { Integration } from "./integration"
|
|||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { PluginHost } from "./plugin/host"
|
import { PluginHost } from "./plugin/host"
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
|
import { SessionV2 } from "./session"
|
||||||
|
import { SessionRuntime } from "./session/runtime"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
|
import { Location } from "./location"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export const ID = Plugin.ID
|
export const ID = Plugin.ID
|
||||||
@@ -149,6 +152,8 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(CommandV2.locationLayer),
|
Layer.provideMerge(CommandV2.locationLayer),
|
||||||
Layer.provideMerge(Integration.locationLayer),
|
Layer.provideMerge(Integration.locationLayer),
|
||||||
Layer.provideMerge(Reference.locationLayer),
|
Layer.provideMerge(Reference.locationLayer),
|
||||||
|
Layer.provideMerge(SessionV2.defaultLayer),
|
||||||
|
Layer.provideMerge(SessionRuntime.layer),
|
||||||
Layer.provideMerge(SkillV2.locationLayer),
|
Layer.provideMerge(SkillV2.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,6 +168,9 @@ export const node = makeLocationNode({
|
|||||||
CommandV2.node,
|
CommandV2.node,
|
||||||
Integration.node,
|
Integration.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
|
SessionV2.node,
|
||||||
|
SessionRuntime.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
|
Location.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { PluginV2 } from "../plugin"
|
|||||||
import { ProviderV2 } from "../provider"
|
import { ProviderV2 } from "../provider"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
|
import { SessionV2 } from "../session"
|
||||||
|
import { SessionMessage } from "../session/message"
|
||||||
|
import { SessionRuntime } from "../session/runtime"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
import { Location } from "../location"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
|
|
||||||
@@ -24,7 +28,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
const integration = yield* Integration.Service
|
const integration = yield* Integration.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const sessionRuntime = yield* SessionRuntime.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options: {},
|
options: {},
|
||||||
@@ -205,6 +212,60 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
create: (input) =>
|
||||||
|
session.create(
|
||||||
|
input.parentID
|
||||||
|
? {
|
||||||
|
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||||
|
parentID: SessionV2.ID.make(input.parentID),
|
||||||
|
title: input.title,
|
||||||
|
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||||
|
model: input.model
|
||||||
|
? {
|
||||||
|
id: ModelV2.ID.make(input.model.id),
|
||||||
|
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||||
|
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||||
|
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||||
|
title: input.title,
|
||||||
|
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||||
|
model: input.model
|
||||||
|
? {
|
||||||
|
id: ModelV2.ID.make(input.model.id),
|
||||||
|
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||||
|
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
get: (sessionID) => session.get(SessionV2.ID.make(sessionID)),
|
||||||
|
messages: (input) =>
|
||||||
|
session.messages({
|
||||||
|
sessionID: SessionV2.ID.make(input.sessionID),
|
||||||
|
limit: input.limit,
|
||||||
|
order: input.order,
|
||||||
|
cursor: input.cursor
|
||||||
|
? { id: SessionMessage.ID.make(input.cursor.id), direction: input.cursor.direction }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
context: (sessionID) => session.context(SessionV2.ID.make(sessionID)),
|
||||||
|
prompt: (input) =>
|
||||||
|
sessionRuntime.prompt({
|
||||||
|
id: input.id ? SessionMessage.ID.make(input.id) : undefined,
|
||||||
|
sessionID: SessionV2.ID.make(input.sessionID),
|
||||||
|
prompt: input.prompt,
|
||||||
|
delivery: input.delivery,
|
||||||
|
resume: input.resume,
|
||||||
|
}),
|
||||||
|
resume: (sessionID) => sessionRuntime.resume(SessionV2.ID.make(sessionID)),
|
||||||
|
wait: (sessionID) => sessionRuntime.wait(SessionV2.ID.make(sessionID)),
|
||||||
|
interrupt: (sessionID) => sessionRuntime.interrupt(SessionV2.ID.make(sessionID)),
|
||||||
|
},
|
||||||
skill: {
|
skill: {
|
||||||
reload: skill.reload,
|
reload: skill.reload,
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
transform: transform(host.reference),
|
transform: transform(host.reference),
|
||||||
reload: () => run(host.reference.reload()),
|
reload: () => run(host.reference.reload()),
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
create: (input) => run(host.session.create(input)),
|
||||||
|
get: (sessionID) => run(host.session.get(sessionID)),
|
||||||
|
messages: (input) => run(host.session.messages(input)),
|
||||||
|
context: (sessionID) => run(host.session.context(sessionID)),
|
||||||
|
prompt: (input) => run(host.session.prompt(input)),
|
||||||
|
resume: (sessionID) => run(host.session.resume(sessionID)),
|
||||||
|
wait: (sessionID) => run(host.session.wait(sessionID)),
|
||||||
|
interrupt: (sessionID) => run(host.session.interrupt(sessionID)),
|
||||||
|
},
|
||||||
skill: {
|
skill: {
|
||||||
transform: transform(host.skill),
|
transform: transform(host.skill),
|
||||||
reload: () => run(host.skill.reload()),
|
reload: () => run(host.skill.reload()),
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ import { ProjectTable } from "./project/sql"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { fromRow } from "./session/info"
|
import { fromRow } from "./session/info"
|
||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
|
import { SessionRuntimeCoordinator } from "./session/runtime-coordinator"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
|
||||||
import { makeGlobalNode } from "./effect/app-node"
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
@@ -194,9 +193,8 @@ export const layer = Layer.effect(
|
|||||||
const db = database.db
|
const db = database.db
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const projects = yield* ProjectV2.Service
|
const projects = yield* ProjectV2.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const runtimeCoordinator = yield* SessionRuntimeCoordinator.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
@@ -389,7 +387,8 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
if (!SessionInput.equivalent(admitted, expected))
|
if (!SessionInput.equivalent(admitted, expected))
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
if (input.resume !== false)
|
||||||
|
return yield* Effect.die("SessionV2.prompt with resume moved to SessionRuntime.Service")
|
||||||
return admitted
|
return admitted
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -438,38 +437,25 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.awaitIdle(sessionID)
|
return yield* Effect.die("SessionV2.wait moved to SessionRuntime.Service")
|
||||||
}),
|
}),
|
||||||
active: execution.active,
|
active: runtimeCoordinator.active,
|
||||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.resume(sessionID)
|
return yield* Effect.die("SessionV2.resume moved to SessionRuntime.Service")
|
||||||
}),
|
}),
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")(() => Effect.die("SessionV2.interrupt moved to SessionRuntime.Service")),
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
|
||||||
),
|
|
||||||
revert: {
|
revert: {
|
||||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
if ((yield* execution.active).has(input.sessionID))
|
return yield* Effect.die("SessionV2.revert.stage moved to SessionRuntime.Service")
|
||||||
return yield* new BusyError({ sessionID: input.sessionID })
|
|
||||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
|
||||||
Effect.provideService(Database.Service, database),
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||||
const session = yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
return yield* Effect.die("SessionV2.revert.clear moved to SessionRuntime.Service")
|
||||||
return yield* SessionRevert.clear(session).pipe(
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||||
const session = yield* result.get(sessionID)
|
const session = yield* result.get(sessionID)
|
||||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
|
||||||
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -485,6 +471,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(Database.defaultLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
|
Layer.provide(SessionRuntimeCoordinator.defaultLayer),
|
||||||
Layer.orDie,
|
Layer.orDie,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -510,9 +497,8 @@ export const node = makeGlobalNode({
|
|||||||
Database.node,
|
Database.node,
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
ProjectV2.node,
|
ProjectV2.node,
|
||||||
SessionExecution.node,
|
SessionRuntimeCoordinator.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
LocationServiceMap.node,
|
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export * as SessionRuntimeCoordinator from "./runtime-coordinator"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
|
import { SessionRunner } from "./runner"
|
||||||
|
import { SessionRunCoordinator } from "./run-coordinator"
|
||||||
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
|
type Drain = (force: boolean) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
|
readonly run: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
|
readonly wake: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void>
|
||||||
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
readonly wait: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntimeCoordinator") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const drains = new Map<SessionSchema.ID, Drain>()
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||||
|
drain: Effect.fnUntraced(function* (sessionID, force) {
|
||||||
|
const drain = drains.get(sessionID)
|
||||||
|
if (!drain) return yield* Effect.die(`No SessionRuntime drain registered for ${sessionID}`)
|
||||||
|
return yield* drain(force)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
active: coordinator.active,
|
||||||
|
run: (sessionID, drain) =>
|
||||||
|
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.run(sessionID))),
|
||||||
|
wake: (sessionID, drain) =>
|
||||||
|
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.wake(sessionID))),
|
||||||
|
interrupt: coordinator.interrupt,
|
||||||
|
wait: coordinator.awaitIdle,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
export * as SessionRuntime from "./runtime"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { Database } from "../database/database"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
|
import { SessionMessage } from "./message"
|
||||||
|
import { Prompt } from "./prompt"
|
||||||
|
import { SessionInput } from "./input"
|
||||||
|
import { SessionRevert } from "./revert"
|
||||||
|
import { SessionRunner } from "./runner"
|
||||||
|
import * as SessionRunnerLLM from "./runner/llm"
|
||||||
|
import { SessionRuntimeCoordinator } from "./runtime-coordinator"
|
||||||
|
import { SessionSchema } from "./schema"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { SessionV2 } from "../session"
|
||||||
|
import {
|
||||||
|
BusyError,
|
||||||
|
MessageNotFoundError,
|
||||||
|
NotFoundError,
|
||||||
|
PromptConflictError,
|
||||||
|
type RevertState,
|
||||||
|
} from "../session"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly prompt: (input: {
|
||||||
|
id?: SessionMessage.ID
|
||||||
|
sessionID: SessionSchema.ID
|
||||||
|
prompt: PromptInput.Prompt
|
||||||
|
delivery?: SessionInput.Delivery
|
||||||
|
resume?: boolean
|
||||||
|
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||||
|
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||||
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
readonly revert: {
|
||||||
|
readonly stage: (input: {
|
||||||
|
sessionID: SessionSchema.ID
|
||||||
|
messageID: SessionMessage.ID
|
||||||
|
files?: boolean
|
||||||
|
}) => Effect.Effect<RevertState, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||||
|
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||||
|
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntime") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const db = database.db
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const sessions = yield* SessionV2.Service
|
||||||
|
const runner = yield* SessionRunner.Service
|
||||||
|
const snapshot = yield* Snapshot.Service
|
||||||
|
const coordinator = yield* SessionRuntimeCoordinator.Service
|
||||||
|
|
||||||
|
const local = Effect.fn("SessionRuntime.local")(function* (sessionID: SessionSchema.ID) {
|
||||||
|
const session = yield* sessions.get(sessionID)
|
||||||
|
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||||
|
return yield* new NotFoundError({ sessionID })
|
||||||
|
return session
|
||||||
|
})
|
||||||
|
|
||||||
|
const drain = (sessionID: SessionSchema.ID) =>
|
||||||
|
Effect.fnUntraced(function* (force: boolean) {
|
||||||
|
yield* local(sessionID).pipe(Effect.orDie)
|
||||||
|
return yield* runner.run({ sessionID, force })
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
prompt: Effect.fn("SessionRuntime.prompt")((input) =>
|
||||||
|
Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* local(input.sessionID)
|
||||||
|
const prompt = resolvePrompt(input.prompt)
|
||||||
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
|
const delivery = input.delivery ?? "steer"
|
||||||
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
|
id: messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt,
|
||||||
|
delivery,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchDefect((defect) =>
|
||||||
|
defect instanceof SessionInput.LifecycleConflict
|
||||||
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
|
: Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!SessionInput.equivalent(admitted, expected))
|
||||||
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
|
if (input.resume !== false) yield* coordinator.wake(admitted.sessionID, drain(admitted.sessionID))
|
||||||
|
return admitted
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
wait: Effect.fn("SessionRuntime.wait")(function* (sessionID) {
|
||||||
|
yield* local(sessionID)
|
||||||
|
yield* coordinator.wait(sessionID)
|
||||||
|
}),
|
||||||
|
active: coordinator.active,
|
||||||
|
resume: Effect.fn("SessionRuntime.resume")(function* (sessionID) {
|
||||||
|
yield* local(sessionID)
|
||||||
|
yield* coordinator.run(sessionID, drain(sessionID))
|
||||||
|
}),
|
||||||
|
interrupt: Effect.fn("SessionRuntime.interrupt")(function* (sessionID) {
|
||||||
|
yield* local(sessionID)
|
||||||
|
yield* Effect.uninterruptible(coordinator.interrupt(sessionID))
|
||||||
|
}),
|
||||||
|
revert: {
|
||||||
|
stage: Effect.fn("SessionRuntime.revert.stage")(function* (input) {
|
||||||
|
const session = yield* local(input.sessionID)
|
||||||
|
if ((yield* coordinator.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
|
||||||
|
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||||
|
Effect.provideService(Database.Service, database),
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provideService(Snapshot.Service, snapshot),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
clear: Effect.fn("SessionRuntime.revert.clear")(function* (sessionID) {
|
||||||
|
const session = yield* local(sessionID)
|
||||||
|
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||||
|
return yield* SessionRevert.clear(session).pipe(
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provideService(Snapshot.Service, snapshot),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
commit: Effect.fn("SessionRuntime.revert.commit")(function* (sessionID) {
|
||||||
|
const session = yield* local(sessionID)
|
||||||
|
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||||
|
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||||
|
Prompt.make({
|
||||||
|
text: input.text,
|
||||||
|
agents: input.agents,
|
||||||
|
files: input.files?.map((file) => {
|
||||||
|
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||||
|
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Database.node, EventV2.node, Location.node, SessionV2.node, SessionRunnerLLM.node, Snapshot.node, SessionRuntimeCoordinator.node],
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import { BackgroundJob } from "../background-job"
|
|||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LocationServiceMap } from "../location-service-map"
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
|
import { SessionRuntime } from "../session/runtime"
|
||||||
import { SessionEvent } from "../session/event"
|
import { SessionEvent } from "../session/event"
|
||||||
import { SessionMessage } from "../session/message"
|
import { SessionMessage } from "../session/message"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
@@ -116,7 +117,9 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||||
)
|
)
|
||||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(parent.location)))
|
const locationLayer = locations.get(parent.location)
|
||||||
|
const agents = yield* AgentV2.Service.pipe(Effect.provide(locationLayer))
|
||||||
|
const runtime = yield* SessionRuntime.Service.pipe(Effect.provide(locationLayer))
|
||||||
const agent = yield* agents.resolve(input.agent)
|
const agent = yield* agents.resolve(input.agent)
|
||||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||||
if (agent.mode === "primary")
|
if (agent.mode === "primary")
|
||||||
@@ -141,8 +144,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
|
|
||||||
const run = Effect.gen(function* () {
|
const run = Effect.gen(function* () {
|
||||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||||
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
yield* runtime.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||||
yield* sessions.resume(child.id)
|
yield* runtime.resume(child.id)
|
||||||
return yield* latestAssistantText(child.id)
|
return yield* latestAssistantText(child.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,7 +169,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
jobs.waitForPromotion(child.id),
|
jobs.waitForPromotion(child.id),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
Effect.all([runtime.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (result?.metadata?.background === true)
|
if (result?.metadata?.background === true)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CommandHooks } from "./command.js"
|
|||||||
import type { IntegrationHooks } from "./integration.js"
|
import type { IntegrationHooks } from "./integration.js"
|
||||||
import type { PluginDomain } from "./plugin.js"
|
import type { PluginDomain } from "./plugin.js"
|
||||||
import type { ReferenceHooks } from "./reference.js"
|
import type { ReferenceHooks } from "./reference.js"
|
||||||
|
import type { SessionDomain } from "./session.js"
|
||||||
import type { SkillHooks } from "./skill.js"
|
import type { SkillHooks } from "./skill.js"
|
||||||
import type { Reload } from "./registration.js"
|
import type { Reload } from "./registration.js"
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ export interface PluginContext {
|
|||||||
readonly integration: IntegrationHooks & Reload
|
readonly integration: IntegrationHooks & Reload
|
||||||
readonly plugin: PluginDomain
|
readonly plugin: PluginDomain
|
||||||
readonly reference: ReferenceHooks & Reload
|
readonly reference: ReferenceHooks & Reload
|
||||||
|
readonly session: SessionDomain
|
||||||
readonly skill: SkillHooks & Reload
|
readonly skill: SkillHooks & Reload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Effect } from "effect"
|
||||||
|
import type { PromptInput, SessionInputAdmitted, SessionMessage, SessionV2Info } from "@opencode-ai/sdk/v2/types"
|
||||||
|
|
||||||
|
export interface SessionDomain {
|
||||||
|
readonly create: (input: {
|
||||||
|
readonly id?: string
|
||||||
|
readonly parentID?: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly agent?: string
|
||||||
|
readonly model?: SessionV2Info["model"]
|
||||||
|
}) => Effect.Effect<SessionV2Info, unknown>
|
||||||
|
readonly get: (sessionID: string) => Effect.Effect<SessionV2Info, unknown>
|
||||||
|
readonly messages: (input: {
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly limit?: number
|
||||||
|
readonly order?: "asc" | "desc"
|
||||||
|
readonly cursor?: { readonly id: string; readonly direction: "previous" | "next" }
|
||||||
|
}) => Effect.Effect<ReadonlyArray<SessionMessage>, unknown>
|
||||||
|
readonly context: (sessionID: string) => Effect.Effect<ReadonlyArray<SessionMessage>, unknown>
|
||||||
|
readonly prompt: (input: {
|
||||||
|
readonly id?: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly prompt: PromptInput
|
||||||
|
readonly delivery?: "steer" | "queue"
|
||||||
|
readonly resume?: boolean
|
||||||
|
}) => Effect.Effect<SessionInputAdmitted, unknown>
|
||||||
|
readonly resume: (sessionID: string) => Effect.Effect<void, unknown>
|
||||||
|
readonly wait: (sessionID: string) => Effect.Effect<void, unknown>
|
||||||
|
readonly interrupt: (sessionID: string) => Effect.Effect<void, unknown>
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { CommandHooks } from "./command.js"
|
|||||||
import type { IntegrationHooks } from "./integration.js"
|
import type { IntegrationHooks } from "./integration.js"
|
||||||
import type { PluginDomain } from "./plugin.js"
|
import type { PluginDomain } from "./plugin.js"
|
||||||
import type { ReferenceHooks } from "./reference.js"
|
import type { ReferenceHooks } from "./reference.js"
|
||||||
|
import type { SessionDomain } from "./session.js"
|
||||||
import type { SkillHooks } from "./skill.js"
|
import type { SkillHooks } from "./skill.js"
|
||||||
import type { Reload } from "./registration.js"
|
import type { Reload } from "./registration.js"
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ export interface PluginContext {
|
|||||||
readonly integration: IntegrationHooks & Reload
|
readonly integration: IntegrationHooks & Reload
|
||||||
readonly plugin: PluginDomain
|
readonly plugin: PluginDomain
|
||||||
readonly reference: ReferenceHooks & Reload
|
readonly reference: ReferenceHooks & Reload
|
||||||
|
readonly session: SessionDomain
|
||||||
readonly skill: SkillHooks & Reload
|
readonly skill: SkillHooks & Reload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { PromptInput, SessionInputAdmitted, SessionMessage, SessionV2Info } from "@opencode-ai/sdk/v2/types"
|
||||||
|
|
||||||
|
export interface SessionDomain {
|
||||||
|
readonly create: (input: {
|
||||||
|
readonly id?: string
|
||||||
|
readonly parentID?: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly agent?: string
|
||||||
|
readonly model?: SessionV2Info["model"]
|
||||||
|
}) => Promise<SessionV2Info>
|
||||||
|
readonly get: (sessionID: string) => Promise<SessionV2Info>
|
||||||
|
readonly messages: (input: {
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly limit?: number
|
||||||
|
readonly order?: "asc" | "desc"
|
||||||
|
readonly cursor?: { readonly id: string; readonly direction: "previous" | "next" }
|
||||||
|
}) => Promise<ReadonlyArray<SessionMessage>>
|
||||||
|
readonly context: (sessionID: string) => Promise<ReadonlyArray<SessionMessage>>
|
||||||
|
readonly prompt: (input: {
|
||||||
|
readonly id?: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly prompt: PromptInput
|
||||||
|
readonly delivery?: "steer" | "queue"
|
||||||
|
readonly resume?: boolean
|
||||||
|
}) => Promise<SessionInputAdmitted>
|
||||||
|
readonly resume: (sessionID: string) => Promise<void>
|
||||||
|
readonly wait: (sessionID: string) => Promise<void>
|
||||||
|
readonly interrupt: (sessionID: string) => Promise<void>
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { SessionRuntime } from "@opencode-ai/core/session/runtime"
|
||||||
import { DateTime, Effect, Stream } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
@@ -20,6 +22,14 @@ const DefaultSessionHistoryLimit = 50
|
|||||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const route = Effect.fn("SessionHandler.route")(function* <A, E>(
|
||||||
|
sessionID: SessionV2.ID,
|
||||||
|
effect: Effect.Effect<A, E, SessionRuntime.Service>,
|
||||||
|
) {
|
||||||
|
const info = yield* session.get(sessionID)
|
||||||
|
return yield* effect.pipe(Effect.provide(locations.get(info.location)))
|
||||||
|
})
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
@@ -159,15 +169,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
"session.prompt",
|
"session.prompt",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
return {
|
return {
|
||||||
data: yield* session
|
data: yield* route(
|
||||||
.prompt({
|
ctx.params.sessionID,
|
||||||
sessionID: ctx.params.sessionID,
|
SessionRuntime.Service.use((runtime) =>
|
||||||
id: ctx.payload.id,
|
runtime.prompt({
|
||||||
prompt: ctx.payload.prompt,
|
sessionID: ctx.params.sessionID,
|
||||||
delivery: ctx.payload.delivery,
|
id: ctx.payload.id,
|
||||||
resume: ctx.payload.resume,
|
prompt: ctx.payload.prompt,
|
||||||
})
|
delivery: ctx.payload.delivery,
|
||||||
.pipe(
|
resume: ctx.payload.resume,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
new SessionNotFoundError({
|
new SessionNotFoundError({
|
||||||
@@ -215,7 +228,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
.handle(
|
.handle(
|
||||||
"session.wait",
|
"session.wait",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* session.wait(ctx.params.sessionID).pipe(
|
yield* route(
|
||||||
|
ctx.params.sessionID,
|
||||||
|
SessionRuntime.Service.use((runtime) => runtime.wait(ctx.params.sessionID)),
|
||||||
|
).pipe(
|
||||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
new SessionNotFoundError({
|
new SessionNotFoundError({
|
||||||
@@ -237,7 +253,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
files: ctx.payload.files,
|
files: ctx.payload.files,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
data: yield* route(
|
||||||
|
ctx.params.sessionID,
|
||||||
|
SessionRuntime.Service.use((runtime) => runtime.revert.stage({ ...ctx.params, ...ctx.payload })),
|
||||||
|
).pipe(
|
||||||
Effect.catchTag(
|
Effect.catchTag(
|
||||||
"Session.NotFoundError",
|
"Session.NotFoundError",
|
||||||
(error) =>
|
(error) =>
|
||||||
@@ -284,7 +303,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
"session.revert.clear",
|
"session.revert.clear",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
yield* route(
|
||||||
|
ctx.params.sessionID,
|
||||||
|
SessionRuntime.Service.use((runtime) => runtime.revert.clear(ctx.params.sessionID)),
|
||||||
|
).pipe(
|
||||||
Effect.catchTag(
|
Effect.catchTag(
|
||||||
"Session.NotFoundError",
|
"Session.NotFoundError",
|
||||||
(error) =>
|
(error) =>
|
||||||
@@ -322,7 +344,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
"session.revert.commit",
|
"session.revert.commit",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* Effect.log("session.revert.commit", { sessionID: ctx.params.sessionID })
|
yield* Effect.log("session.revert.commit", { sessionID: ctx.params.sessionID })
|
||||||
yield* session.revert.commit(ctx.params.sessionID).pipe(
|
yield* route(
|
||||||
|
ctx.params.sessionID,
|
||||||
|
SessionRuntime.Service.use((runtime) => runtime.revert.commit(ctx.params.sessionID)),
|
||||||
|
).pipe(
|
||||||
Effect.catchTag(
|
Effect.catchTag(
|
||||||
"Session.NotFoundError",
|
"Session.NotFoundError",
|
||||||
(error) =>
|
(error) =>
|
||||||
@@ -407,7 +432,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
.handle(
|
.handle(
|
||||||
"session.interrupt",
|
"session.interrupt",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* session.interrupt(ctx.params.sessionID)
|
yield* route(
|
||||||
|
ctx.params.sessionID,
|
||||||
|
SessionRuntime.Service.use((runtime) => runtime.interrupt(ctx.params.sessionID)),
|
||||||
|
)
|
||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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. **Implemented in this draft.**
|
||||||
|
2. Move `prompt`, `resume`, `wait`, `interrupt`, `active`, and location-sensitive `revert` operations from `SessionV2.Service` into the runtime service. **Implemented in this draft for the new runtime path; old `SessionV2` entrypoints are left as compatibility stubs and should be removed once callers migrate.**
|
||||||
|
3. Update server route handlers to route location-sensitive requests at the API boundary by resolving the session location and providing that location runtime. **Implemented in this draft.**
|
||||||
|
4. Add `ctx.session` to `PluginHost` by composing `SessionV2.Service` and the location session runtime. **Implemented in this draft.**
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user