From 5394ff091adb3ab882267ffeeeab739b0f018aa7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:55:00 -0400 Subject: [PATCH 01/13] feat(core): admit v2 skill guidance --- CONTEXT.md | 2 + packages/core/src/location-layer.ts | 8 +- packages/core/src/permission.ts | 5 + packages/core/src/session/context-epoch.ts | 30 +++-- packages/core/src/session/projector.ts | 13 +- packages/core/src/session/runner/llm.ts | 86 +++++++++---- packages/core/src/skill-guidance.ts | 79 ++++++++++++ packages/core/src/skill.ts | 7 +- packages/core/src/tool/skill.ts | 22 ++-- .../core/test/session-runner-recorded.test.ts | 5 + packages/core/test/session-runner.test.ts | 81 ++++++++++++ packages/core/test/skill-guidance.test.ts | 115 ++++++++++++++++++ packages/core/test/tool-skill.test.ts | 2 +- specs/v2/schema-changelog.md | 17 +++ specs/v2/session.md | 34 +++++- 15 files changed, 444 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/skill-guidance.ts create mode 100644 packages/core/test/skill-guidance.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 4a3bc6d22552..2027421ba3d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -65,6 +65,8 @@ The point immediately before a provider call, after durable input promotion and - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. +- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only described skills permitted for that agent; skill bodies remain intentionally loaded through the permission-checked `skill` tool. +- Switching the selected agent clears the active **Context Epoch** so agent-specific context cannot remain in the active baseline. Epoch creation is fenced against the authoritative effective agent, and the runner rechecks that agent before provider dispatch. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c1621c404f66..4b6663e48862 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -26,6 +26,7 @@ import { ProjectReference } from "./project-reference" import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" import { SkillV2 } from "./skill" +import { SkillGuidance } from "./skill-guidance" import { BuiltInTools } from "./tool/builtins" import { ToolRegistry } from "./tool/registry" import { ApplicationTools } from "./tool/application-tools" @@ -68,6 +69,7 @@ export class LocationServiceMap extends LayerMap.Service()(" ).pipe(Layer.provideMerge(location)) const commits = FileMutation.locationLayer.pipe(Layer.provide(services)) const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services)) + const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) const resources = ToolOutputStore.layer.pipe(Layer.provide(services)) const todos = SessionTodo.layer.pipe(Layer.provide(services)) const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) @@ -80,7 +82,11 @@ export class LocationServiceMap extends LayerMap.Service()(" Layer.provide(questions), ) const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) - const runner = SessionRunnerLLM.defaultLayer.pipe(Layer.provide(services), Layer.provide(model)) + const runner = SessionRunnerLLM.defaultLayer.pipe( + Layer.provide(services), + Layer.provide(model), + Layer.provide(skillGuidance), + ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) return Layer.mergeAll( services, diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bb75c7c9f5e5..36a0574d3691 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -115,6 +115,11 @@ export function merge(...rulesets: Ruleset[]): Ruleset { return rulesets.flat() } +export function disabled(action: string, ruleset: Ruleset) { + const rule = ruleset.findLast((rule) => Wildcard.match(action, rule.action)) + return rule?.resource === "*" && rule.effect === "deny" +} + export interface Interface { readonly ask: (input: AssertInput) => EffectRuntime.Effect readonly assert: (input: AssertInput) => EffectRuntime.Effect diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index f438d6f3ed0d..3eb1b93d8bfc 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -2,6 +2,7 @@ export * as SessionContextEpoch from "./context-epoch" import { and, eq, isNull, lt, or, sql } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" +import { AgentV2 } from "../agent" import type { Database } from "../database/database" import { EventV2 } from "../event" import { Location } from "../location" @@ -18,6 +19,7 @@ type DatabaseService = Database.Interface["db"] class RevisionMismatch extends Error {} class LocationMismatch extends Error {} +export class AgentMismatch extends Error {} const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => attempt().pipe( @@ -35,11 +37,12 @@ interface Prepared { export function initialize( db: DatabaseService, - context: SystemContextRegistry.Interface, + context: Effect.Effect, sessionID: SessionSchema.ID, location: Location.Ref, + agent: AgentV2.ID, ): Effect.Effect { - return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe( + return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe( Effect.withSpan("SessionContextEpoch.initialize"), ) } @@ -47,11 +50,12 @@ export function initialize( export function prepare( db: DatabaseService, events: EventV2.Interface, - context: SystemContextRegistry.Interface, + context: Effect.Effect, sessionID: SessionSchema.ID, location: Location.Ref, + agent: AgentV2.ID, ): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe( + return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( Effect.withSpan("SessionContextEpoch.prepare"), ) } @@ -59,14 +63,15 @@ export function prepare( const prepareOnce = Effect.fnUntraced(function* ( db: DatabaseService, events: EventV2.Interface, - context: SystemContextRegistry.Interface, + context: Effect.Effect, sessionID: SessionSchema.ID, location: Location.Ref, + agent: AgentV2.ID, ) { - const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" }) + const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" }) if (!stored) { const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, location, generation) + const baselineSeq = yield* insert(db, sessionID, location, agent, generation) return { baseline: generation.baseline, baselineSeq } } @@ -95,13 +100,14 @@ const prepareOnce = Effect.fnUntraced(function* ( const initializeOnce = Effect.fnUntraced(function* ( db: DatabaseService, - context: SystemContextRegistry.Interface, + context: Effect.Effect, sessionID: SessionSchema.ID, location: Location.Ref, + agent: AgentV2.ID, ) { if (yield* exists(db, sessionID)) return - const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, location, generation) + const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) + const baselineSeq = yield* insert(db, sessionID, location, agent, generation) return { baseline: generation.baseline, baselineSeq } }) @@ -159,6 +165,7 @@ const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, location: Location.Ref, + agent: AgentV2.ID, generation: SystemContext.Generation, ) { return yield* db @@ -166,7 +173,7 @@ const insert = Effect.fnUntraced(function* ( () => Effect.gen(function* () { const placed = yield* db - .select({ sessionID: SessionTable.id }) + .select({ agent: SessionTable.agent }) .from(SessionTable) .where( and( @@ -180,6 +187,7 @@ const insert = Effect.fnUntraced(function* ( .get() .pipe(Effect.orDie) if (!placed) return yield* Effect.die(new LocationMismatch()) + if ((placed.agent ?? "build") !== agent) return yield* Effect.die(new AgentMismatch()) const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) yield* db .insert(SessionContextEpochTable) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 42053ce733d3..03aaa20967d3 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -347,14 +347,19 @@ export const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => - db + yield* events.project(SessionEvent.AgentSwitched, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return db .update(SessionTable) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() - .pipe(Effect.orDie, Effect.andThen(run(db, event))), - ) + .pipe( + Effect.orDie, + Effect.andThen(run(db, event)), + Effect.andThen(SessionContextEpoch.reset(db, event.data.sessionID)), + ) + }) yield* events.project(SessionEvent.ModelSwitched, (event) => Effect.gen(function* () { yield* db diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 8013e48d4953..0fe6718823ab 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,22 +1,24 @@ import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect" +import { AgentV2 } from "../../agent" +import { Database } from "../../database/database" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" -import { SessionSchema } from "../schema" +import { QuestionV2 } from "../../question" +import { SkillGuidance } from "../../skill-guidance" +import { SystemContext } from "../../system-context" +import { SystemContextRegistry } from "../../system-context-registry" +import { ToolRegistry } from "../../tool/registry" +import { SessionContextEpoch } from "../context-epoch" import { SessionEvent } from "../event" +import { SessionInput } from "../input" +import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { Service, StepLimitExceededError } from "./index" +import { type RunError, Service, StepLimitExceededError } from "./index" +import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" -import { ToolRegistry } from "../../tool/registry" -import { SessionRunnerModel } from "./model" -import { Database } from "../../database/database" -import { SessionInput } from "../input" -import { QuestionV2 } from "../../question" -import { SystemContextRegistry } from "../../system-context-registry" -import { SessionContextEpoch } from "../context-epoch" -import { AgentV2 } from "../../agent" /** * Runs one durable coding-agent Session until it settles. @@ -33,16 +35,7 @@ import { AgentV2 } from "../../agent" * - [ ] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly - * - [x] Load Session placement and chronological projected V2 history. - * - [x] Resolve the selected model through the location-scoped runner environment. - * - [ ] Load the selected agent and effective permissions. - * - [ ] Build provider/model-specific base instructions and environment facts. - * - [x] Load global and upward project `AGENTS.md` instructions. - * - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read. - * - [ ] List available skills in the system prompt and expose a tool for loading skill bodies. - * - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media. - * - [ ] Apply steering reminders, plugin transforms, and structured-output policy. - * - [ ] Compact or summarize history when context pressure requires it. + * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. * * - One provider turn * - [x] Translate every projected V2 Session message variant into canonical @@ -90,6 +83,7 @@ export const layer = Layer.effect( const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const systemContext = yield* SystemContextRegistry.Service + const skillGuidance = yield* SkillGuidance.Service const db = (yield* Database.Service).db const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) @@ -129,14 +123,35 @@ export const layer = Layer.effect( const isQuestionRejected = (cause: Cause.Cause) => cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) - const runTurn = Effect.fn("SessionRunner.runTurn")(function* ( + class RetryTurn extends Error { + constructor(readonly promotion: "steer" | "queue" | undefined) { + super() + } + } + + const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: "steer" | "queue" | undefined, ) { const session = yield* getSession(sessionID) - const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location) - const model = yield* models.resolve(session) const agent = yield* agents.resolve(session.agent) + const agentID = agent?.id ?? AgentV2.defaultID + const currentSystemContext = Effect.all([systemContext.load(), skillGuidance.load(agentID)], { + concurrency: "unbounded", + }).pipe(Effect.map(SystemContext.combine)) + const initialized = yield* SessionContextEpoch.initialize( + db, + currentSystemContext, + session.id, + session.location, + agentID, + ).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionContextEpoch.AgentMismatch + ? Effect.die(new RetryTurn(promotion)) + : Effect.die(defect), + ), + ) const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { @@ -148,7 +163,17 @@ export const layer = Layer.effect( } } const system = - initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location)) + initialized ?? + (yield* SessionContextEpoch.prepare(db, events, currentSystemContext, session.id, session.location, agentID).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionContextEpoch.AgentMismatch + ? Effect.die(new RetryTurn(undefined)) + : Effect.die(defect), + ), + )) + const current = yield* getSession(sessionID) + if ((yield* agents.resolve(current.agent))?.id !== agent?.id) return yield* runTurn(sessionID, undefined) + const model = yield* models.resolve(current) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, @@ -160,11 +185,11 @@ export const layer = Layer.effect( }) const publisher = createLLMEventPublisher(events, { sessionID: session.id, - agent: agent?.id ?? "build", + agent: agentID, model: { id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), + ...(current.model?.variant === undefined ? {} : { variant: current.model.variant }), }, }) const withPublication = Semaphore.makeUnsafe(1).withPermit @@ -245,6 +270,15 @@ export const layer = Layer.effect( }), ) }, Effect.scoped) + const runTurn: ( + sessionID: SessionSchema.ID, + promotion: "steer" | "queue" | undefined, + ) => Effect.Effect = (sessionID, promotion) => + runTurnAttempt(sessionID, promotion).pipe( + Effect.catchDefect((defect) => + defect instanceof RetryTurn ? runTurn(sessionID, defect.promotion) : Effect.die(defect), + ), + ) const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID diff --git a/packages/core/src/skill-guidance.ts b/packages/core/src/skill-guidance.ts new file mode 100644 index 000000000000..8c7cc3378083 --- /dev/null +++ b/packages/core/src/skill-guidance.ts @@ -0,0 +1,79 @@ +export * as SkillGuidance from "./skill-guidance" + +import { pathToFileURL } from "url" +import { Context, Effect, Layer, Schema } from "effect" +import { AgentV2 } from "./agent" +import { PermissionV2 } from "./permission" +import { PluginBoot } from "./plugin/boot" +import { SkillV2 } from "./skill" +import { SystemContext } from "./system-context" + +const Summary = Schema.Struct({ + name: Schema.String, + description: Schema.String, + location: Schema.String, +}) +type Summary = typeof Summary.Type + +const render = (skills: ReadonlyArray) => + [ + "Skills provide specialized instructions and workflows for specific tasks.", + "Use the skill tool to load a skill when a task matches its description.", + ...(skills.length === 0 + ? ["No skills are currently available."] + : [ + "", + ...skills.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${skill.location}`, + " ", + ]), + "", + ]), + ].join("\n") + +export interface Interface { + readonly load: (agentID: AgentV2.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SkillGuidance") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const boot = yield* PluginBoot.Service + const skills = yield* SkillV2.Service + + return Service.of({ + load: Effect.fn("SkillGuidance.load")(function* (agentID) { + yield* boot.wait() + const agent = yield* agents.get(agentID) + if (!agent || PermissionV2.disabled("skill", agent.permissions)) return SystemContext.empty + const available = SkillV2.available(yield* skills.list(), agent) + .flatMap((skill) => + skill.description === undefined + ? [] + : [{ name: skill.name, description: skill.description, location: pathToFileURL(skill.location).href }], + ) + .toSorted((a, b) => a.name.localeCompare(b.name)) + return SystemContext.make({ + key: SystemContext.Key.make("core/skill-guidance"), + codec: Schema.toCodecJson(Schema.Array(Summary)), + load: Effect.succeed(available), + baseline: render, + update: (_previous, current) => + [ + "The available skills have changed. This list supersedes the previous available skills list.", + render(current), + ].join("\n"), + removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.", + }) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index daf9046fe7c8..23031e7c7037 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -54,6 +54,9 @@ export class Info extends Schema.Class("SkillV2.Info")({ content: Schema.String, }) {} +export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => + skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional), @@ -156,9 +159,7 @@ export const layer = Layer.effect( forAgent: Effect.fn("SkillV2.forAgent")(function* (id) { const current = yield* agent.get(id) if (!current) return [] - return (yield* list()).filter( - (skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny", - ) + return available(yield* list(), current) }), }) }), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 688a76d5f054..2f04adca75b0 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -25,18 +25,13 @@ export const Success = Schema.Struct({ resource: ToolOutputStore.Resource.pipe(Schema.optional), }) -export const description = (skills: ReadonlyArray) => - [ - "Load a specialized skill when the task at hand matches one of the available skills listed below.", - "", - "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", - "", - "The skill name must match one of the available skills listed below:", - "", - ...(skills.length - ? skills.map((skill) => `- **${skill.name}**: ${skill.description ?? "No description provided."}`) - : ["No skills are currently available."]), - ].join("\n") +export const description = [ + "Load a specialized skill when the task at hand matches one of the available skills in the system context.", + "", + "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", + "", + "The skill name must match one of the available skills in the system context.", +].join("\n") export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { const directory = path.dirname(skill.location) @@ -70,9 +65,8 @@ export const layer = Layer.effectDiscard( const skills = yield* SkillV2.Service const resources = yield* ToolOutputStore.Service yield* boot.wait() - const available = yield* skills.list() const definition = Tool.make({ - description: description(available), + description, parameters: Parameters, success: Success, toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index b05638f63f02..65a78f378729 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -21,6 +21,8 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SkillGuidance } from "@opencode-ai/core/skill-guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -59,6 +61,7 @@ const model = OpenAIChat.route .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) const systemContext = SystemContextRegistry.layer +const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(database), Layer.provide(store), @@ -68,6 +71,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(models), Layer.provide(systemContext), Layer.provide(agents), + Layer.provide(skillGuidance), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -96,6 +100,7 @@ const it = testEffect( registry, models, systemContext, + skillGuidance, runner, coordinator, execution, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 933b7abe0774..56ac1aa634e8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -43,8 +43,10 @@ import { import { SessionStore } from "@opencode-ai/core/session/store" import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { SkillGuidance } from "@opencode-ai/core/skill-guidance" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AgentV2 } from "@opencode-ai/core/agent" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -154,6 +156,7 @@ let systemBaseline = "Initial context" let systemRemoved = false let systemUnavailable = false let systemLoadHook = Effect.void +const skillBaselines = new Map() const systemContext = Layer.effectDiscard( SystemContextRegistry.Service.pipe( Effect.flatMap((registry) => @@ -183,6 +186,21 @@ const systemContext = Layer.effectDiscard( ), ), ).pipe(Layer.provideMerge(SystemContextRegistry.layer)) +const skillGuidance = Layer.mock(SkillGuidance.Service, { + load: (agentID) => + Effect.succeed( + skillBaselines.has(agentID) + ? SystemContext.make({ + key: SystemContext.Key.make("test/skill-guidance"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(skillBaselines.get(agentID)!), + baseline: String, + update: (_previous, current) => current, + removed: () => "Skill guidance removed", + }) + : SystemContext.empty, + ), +}) const runner = SessionRunnerLLM.layer.pipe( Layer.provide(database), Layer.provide(store), @@ -192,6 +210,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(models), Layer.provide(systemContext), Layer.provide(agents), + Layer.provide(skillGuidance), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -222,6 +241,7 @@ const it = testEffect( echo, models, systemContext, + skillGuidance, runner, coordinator, execution, @@ -256,6 +276,7 @@ const setup = Effect.gen(function* () { systemRemoved = false systemUnavailable = false systemLoadHook = Effect.void + skillBaselines.clear() responses = undefined streamFailure = undefined responseStream = undefined @@ -805,6 +826,66 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + skillBaselines.set(AgentV2.ID.make("build"), "Build skills") + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: "reviewer", + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context\n\nBuild skills"], + ["Initial context\n\nReviewer skills"], + ]) + }), + ) + + it.effect("retries first-epoch preparation when the selected agent changes during observation", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + skillBaselines.set(AgentV2.ID.make("build"), "Build skills") + skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") + let switched = false + systemLoadHook = Effect.suspend(() => { + if (switched) return Effect.void + switched = true + return events + .publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: "reviewer", + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context\n\nReviewer skills"], + ]) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/skill-guidance.test.ts b/packages/core/test/skill-guidance.test.ts new file mode 100644 index 000000000000..139b0a442dbd --- /dev/null +++ b/packages/core/test/skill-guidance.test.ts @@ -0,0 +1,115 @@ +import path from "path" +import { pathToFileURL } from "url" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillGuidance } from "@opencode-ai/core/skill-guidance" +import { SystemContext } from "@opencode-ai/core/system-context" +import { it } from "./lib/effect" + +const build = AgentV2.ID.make("build") +const effect = new SkillV2.Info({ + name: "effect", + description: "Build applications with Effect", + location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), + content: "Effect guidance", +}) +const hidden = new SkillV2.Info({ + name: "hidden", + location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), + content: "Undescribed guidance", +}) +const denied = new SkillV2.Info({ + name: "denied", + description: "Must not be advertised", + location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), + content: "Denied guidance", +}) + +const layer = (agent: AgentV2.Info, list: () => SkillV2.Info[], wait: () => void = () => {}) => + SkillGuidance.layer.pipe( + Layer.provide(Layer.mock(AgentV2.Service, { get: (id) => Effect.succeed(id === agent.id ? agent : undefined) })), + Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), + Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), + ) + +describe("SkillGuidance", () => { + it.effect("renders described agent skills and reconciles the complete available list", () => { + const agent = new AgentV2.Info({ + ...AgentV2.Info.empty(build), + permissions: [{ action: "skill", resource: "denied", effect: "deny" }], + }) + let skills = [hidden, denied, effect] + let waited = 0 + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize)) + + expect(waited).toBe(1) + expect(initialized.baseline).toBe( + [ + "Skills provide specialized instructions and workflows for specific tasks.", + "Use the skill tool to load a skill when a task matches its description.", + "", + " ", + " effect", + " Build applications with Effect", + ` ${pathToFileURL(effect.location).href}`, + " ", + "", + ].join("\n"), + ) + + skills = [] + expect( + yield* guidance + .load(build) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))), + ).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining("No skills are currently available."), + }) + }).pipe( + Effect.provide( + layer( + agent, + () => skills, + () => waited++, + ), + ), + ) + }) + + it.effect("omits guidance when the selected agent denies all skills", () => { + const agent = new AgentV2.Info({ + ...AgentV2.Info.empty(build), + permissions: [{ action: "skill", resource: "*", effect: "deny" }], + }) + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + baseline: "", + snapshot: {}, + }) + }).pipe(Effect.provide(layer(agent, () => [effect]))) + }) + + it.effect("retains specifically allowed skills after a global denial", () => { + const agent = new AgentV2.Info({ + ...AgentV2.Info.empty(build), + permissions: [ + { action: "skill", resource: "*", effect: "deny" }, + { action: "skill", resource: "effect", effect: "allow" }, + ], + }) + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + expect((yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).baseline).toContain( + "effect", + ) + }).pipe(Effect.provide(layer(agent, () => [effect]))) + }) +}) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index fad2544c6857..7e8c0e728ee8 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -97,7 +97,7 @@ describe("SkillTool", () => { expect(bootWaited).toBe(true) expect((yield* registry.definitions())[0]).toMatchObject({ name: "skill", - description: expect.stringContaining("**effect**: Use Effect"), + description: SkillTool.description, }) expect( yield* registry.execute({ diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 5c5980a2ede7..7eee20ece4a8 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -768,3 +768,20 @@ Change: Compatibility: - Watcher-backed per-file `Refreshable` instruction observations, configured sources, nested discovery, and plugin-defined context remain follow-up slices. + +## 2026-06-05: Admit Selected-Agent Skill Guidance + +Affected schema: + +- No database, synchronized event, public HTTP API, or generated SDK schema changes. + +Change: + +- Compose selected-agent, permission-filtered available-skill guidance with Location-wide System Context before Context Epoch admission. +- Keep skill bodies behind the existing permission-checked `skill` tool and remove the unfiltered skill list from its Location-wide definition. +- Clear the active Context Epoch after an agent switch, fence epoch creation against the authoritative effective agent, and recheck the agent before provider dispatch. +- Add the canonical V1-to-V2 runtime-context parity checklist to `specs/v2/session.md`. + +Compatibility: + +- Existing experimental V2 Context Epochs reconcile the new source at the next safe provider-turn boundary. diff --git a/specs/v2/session.md b/specs/v2/session.md index 00f7c2ba7265..167e9141235f 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -39,7 +39,7 @@ Projected hosted tools preserve call-side and settlement-side provider metadata ## Context Epochs -V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, and ambient global/upward-project `AGENTS.md` files are the initial registered sources. +V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. @@ -65,7 +65,7 @@ Client Runner System Context Registry C │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` -Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move. +Model switches and completed compactions request lazy baseline replacement. An agent switch or Session move clears the epoch so a complete effective baseline must initialize before provider dispatch. Epoch creation is fenced against the authoritative Session Location and effective agent, preventing an old runner from recreating stale privileged context after a concurrent change. ```text Session Epoch @@ -97,6 +97,36 @@ Current Context Epoch follow-ups: - Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed. - Add clustered Session execution ownership and stale-runtime fencing. +## V1 Runtime Context Parity + +This is the canonical checklist for model-visible runtime context still needed before the V2 runner replaces V1. Keep each behavior in its owning boundary rather than treating all model-visible text as a durable Context Source. Update this table in the PR that changes a status. + +Status: `complete` is usable in the native V2 path, `partial` covers only part of V1 behavior, and `missing` has no native V2 equivalent. + +| Boundary | Behavior | Status | Remaining V2 work | +| -------------------------- | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Durable Context Source | Environment facts and host-local date | partial | Add selected provider/model identity without making model selection a stale Location-wide value. | +| Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | +| Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | +| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe provider-turn boundary. | +| Durable Context Source | Selected-agent available skill guidance and skill-body loading | complete | None. | +| Per-turn request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | +| Per-turn request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | +| Per-turn request assembly | Provider/model-specific base instructions | missing | Select the provider-family baseline unless the effective agent overrides it. | +| Per-turn request assembly | Policy-filtered built-in, MCP, plugin, and structured-output tools | partial | Materialize definitions for the effective agent and request. | +| Per-turn request assembly | Per-prompt system text and tool overrides | missing | Design admission and durable replay semantics before exposing them. | +| Per-turn request assembly | Steering, plan/build-switch, and final-step reminders | missing | Add only reminders whose behavior remains part of V2. | +| Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | +| Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | +| Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | +| Per-turn request assembly | Automatic/context-pressure compaction | partial | V2 replays completed compactions and replaces epochs but cannot initiate compaction. | +| Prompt/reference expansion | Durable typed prompt attachments | complete | None. | +| Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. | +| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. | +| Prompt/reference expansion | Agent-reference expansion | missing | Produce permission-aware model-visible task guidance. | +| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. | +| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. | + Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. Inbox delivery is explicit: From fa4ed8bd83cdc702b414d518ed4067c28cf0efab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:56:40 -0400 Subject: [PATCH 02/13] test(core): cover queued skill guidance retry --- packages/core/test/session-runner.test.ts | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 56ac1aa634e8..19d92186fb09 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -886,6 +886,42 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("opens a queued activity once when the selected agent changes during observation", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + skillBaselines.set(AgentV2.ID.make("build"), "Build skills") + skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") + let switched = false + systemLoadHook = Effect.suspend(() => { + if (switched) return Effect.void + switched = true + return events + .publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: "reviewer", + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Queued" }), + delivery: "queue", + resume: false, + }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup From 68d82635a3a7b09a59af21fb75a83d55913078b1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:10:20 -0400 Subject: [PATCH 03/13] refactor(core): clarify v2 context ownership --- AGENTS.md | 1 + CONTEXT.md | 7 ++- packages/core/package.json | 1 + packages/core/src/agent.ts | 2 + packages/core/src/instruction-context.ts | 4 +- packages/core/src/location-layer.ts | 4 +- packages/core/src/plugin/agent.ts | 2 +- packages/core/src/session/context-epoch.ts | 5 +- .../src/session/{context.ts => history.ts} | 6 +- packages/core/src/session/projector.ts | 2 +- packages/core/src/session/runner/index.ts | 2 +- packages/core/src/session/runner/llm.ts | 59 +++++++++++-------- packages/core/src/session/sql.ts | 2 +- packages/core/src/session/store.ts | 6 +- .../{skill-guidance.ts => skill/guidance.ts} | 12 ++-- .../builtins.ts} | 10 ++-- .../index.ts} | 2 +- .../registry.ts} | 4 +- packages/core/src/tool/skill.ts | 7 +-- .../core/test/instruction-context.test.ts | 2 +- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 53 ++++++++++++++++- .../guidance.test.ts} | 4 +- .../builtins.test.ts} | 8 +-- .../index.test.ts} | 2 +- .../registry.test.ts} | 4 +- packages/core/test/tool-skill.test.ts | 2 +- specs/v2/schema-changelog.md | 4 +- specs/v2/session.md | 2 +- 29 files changed, 143 insertions(+), 80 deletions(-) rename packages/core/src/session/{context.ts => history.ts} (94%) rename packages/core/src/{skill-guidance.ts => skill/guidance.ts} (90%) rename packages/core/src/{system-context-builtins.ts => system-context/builtins.ts} (85%) rename packages/core/src/{system-context.ts => system-context/index.ts} (99%) rename packages/core/src/{system-context-registry.ts => system-context/registry.ts} (93%) rename packages/core/test/{skill-guidance.test.ts => skill/guidance.test.ts} (97%) rename packages/core/test/{system-context-builtins.test.ts => system-context/builtins.test.ts} (97%) rename packages/core/test/{system-context.test.ts => system-context/index.test.ts} (99%) rename packages/core/test/{system-context-registry.test.ts => system-context/registry.test.ts} (98%) diff --git a/AGENTS.md b/AGENTS.md index 6ed0761b8977..926343679183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,3 +149,4 @@ const table = sqliteTable("session", { - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. - Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. +- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index 2027421ba3d8..e2a57225684d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,10 @@ OpenCode sessions preserve durable conversational history while assembling the r The structured collection of contextual facts presented to the model as initial instructions and chronological updates. _Avoid_: System prompt +**Session History**: +The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs. +_Avoid_: Session Context + **Context Source**: One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources. _Avoid_: Prompt fragment @@ -38,6 +42,7 @@ The point immediately before a provider call, after durable input promotion and ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. +- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state. - The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**. - A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. - A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. @@ -66,7 +71,7 @@ The point immediately before a provider call, after durable input promotion and - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only described skills permitted for that agent; skill bodies remain intentionally loaded through the permission-checked `skill` tool. -- Switching the selected agent clears the active **Context Epoch** so agent-specific context cannot remain in the active baseline. Epoch creation is fenced against the authoritative effective agent, and the runner rechecks that agent before provider dispatch. +- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. diff --git a/packages/core/package.json b/packages/core/package.json index 561cb93a232d..36a5fcdd4ede 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "exports": { "./public": "./src/public/index.ts", "./session/runner": "./src/session/runner/index.ts", + "./system-context": "./src/system-context/index.ts", "./*": "./src/*.ts" }, "imports": { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 97f5156d0cb6..e2a1f4407f94 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -10,6 +10,8 @@ import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type +export const defaultID = ID.make("build") +export const effectiveID = (id: string | null | undefined) => ID.make(id ?? defaultID) export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index f34a016da264..1546abc0c148 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -7,8 +7,8 @@ import { Flag } from "./flag/flag" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" -import { SystemContext } from "./system-context" -import { SystemContextRegistry } from "./system-context-registry" +import { SystemContext } from "./system-context/index" +import { SystemContextRegistry } from "./system-context/registry" class File extends Schema.Class("InstructionContext.File")({ path: AbsolutePath, diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 4b6663e48862..e96f242c4a4c 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -26,7 +26,7 @@ import { ProjectReference } from "./project-reference" import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" import { SkillV2 } from "./skill" -import { SkillGuidance } from "./skill-guidance" +import { SkillGuidance } from "./skill/guidance" import { BuiltInTools } from "./tool/builtins" import { ToolRegistry } from "./tool/registry" import { ApplicationTools } from "./tool/application-tools" @@ -41,7 +41,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionRunCoordinator } from "./session/run-coordinator" -import { SystemContextBuiltIns } from "./system-context-builtins" +import { SystemContextBuiltIns } from "./system-context/builtins" import { FetchHttpClient } from "effect/unstable/http" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 64e9302f937f..1e4dee5b328e 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -123,7 +123,7 @@ export const Plugin = PluginV2.define({ ] yield* agent.update((editor) => { - editor.update(AgentV2.ID.make("build"), (item) => { + editor.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 3eb1b93d8bfc..23fa94ae4e74 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -6,8 +6,7 @@ import { AgentV2 } from "../agent" import type { Database } from "../database/database" import { EventV2 } from "../event" import { Location } from "../location" -import { SystemContext } from "../system-context" -import { SystemContextRegistry } from "../system-context-registry" +import { SystemContext } from "../system-context/index" import { ContextSnapshotDecodeError } from "./error" import { SessionEvent } from "./event" import { SessionInput } from "./input" @@ -187,7 +186,7 @@ const insert = Effect.fnUntraced(function* ( .get() .pipe(Effect.orDie) if (!placed) return yield* Effect.die(new LocationMismatch()) - if ((placed.agent ?? "build") !== agent) return yield* Effect.die(new AgentMismatch()) + if (AgentV2.effectiveID(placed.agent) !== agent) return yield* Effect.die(new AgentMismatch()) const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) yield* db .insert(SessionContextEpochTable) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/history.ts similarity index 94% rename from packages/core/src/session/context.ts rename to packages/core/src/session/history.ts index 3148dbb8fe61..66af53367948 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/history.ts @@ -62,7 +62,7 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => ), ) -export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { +export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const [epoch, compaction] = yield* Effect.all( [ db @@ -78,7 +78,7 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) }) -export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ( +export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, baselineSeq: number, @@ -89,4 +89,4 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ) }) -export * as SessionContext from "./context" +export * as SessionHistory from "./history" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 03aaa20967d3..fdc9773553d5 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -357,7 +357,7 @@ export const layer = Layer.effectDiscard( .pipe( Effect.orDie, Effect.andThen(run(db, event)), - Effect.andThen(SessionContextEpoch.reset(db, event.data.sessionID)), + Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), ) }) yield* events.project(SessionEvent.ModelSwitched, (event) => diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index e9dd53253dc0..819846aa29d8 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -5,7 +5,7 @@ import { Context, Effect, Schema } from "effect" import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" -import type { SystemContext } from "../../system-context" +import type { SystemContext } from "../../system-context/index" export class StepLimitExceededError extends Schema.TaggedErrorClass()( "SessionRunner.StepLimitExceededError", diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0fe6718823ab..29584e2119eb 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -6,9 +6,9 @@ import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" -import { SkillGuidance } from "../../skill-guidance" -import { SystemContext } from "../../system-context" -import { SystemContextRegistry } from "../../system-context-registry" +import { SystemContext } from "../../system-context/index" +import { SystemContextRegistry } from "../../system-context/registry" +import { SkillGuidance } from "../../skill/guidance" import { ToolRegistry } from "../../tool/registry" import { SessionContextEpoch } from "../context-epoch" import { SessionEvent } from "../event" @@ -124,34 +124,42 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) class RetryTurn extends Error { - constructor(readonly promotion: "steer" | "queue" | undefined) { + constructor(readonly promotion: SessionInput.Delivery | undefined) { super() } } + const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) => + Effect.catchDefect((defect) => + defect instanceof SessionContextEpoch.AgentMismatch ? Effect.die(new RetryTurn(promotion)) : Effect.die(defect), + ) + + const loadSystemContext = (sessionID: SessionSchema.ID) => + getSession(sessionID).pipe( + Effect.flatMap((session) => + Effect.all([systemContext.load(), agents.resolve(session.agent)], { concurrency: "unbounded" }), + ), + Effect.flatMap(([context, agent]) => + Effect.all([Effect.succeed(context), skillGuidance.load(agent?.id ?? AgentV2.defaultID)], { + concurrency: "unbounded", + }), + ), + Effect.map(SystemContext.combine), + ) const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, - promotion: "steer" | "queue" | undefined, + promotion: SessionInput.Delivery | undefined, ) { const session = yield* getSession(sessionID) const agent = yield* agents.resolve(session.agent) const agentID = agent?.id ?? AgentV2.defaultID - const currentSystemContext = Effect.all([systemContext.load(), skillGuidance.load(agentID)], { - concurrency: "unbounded", - }).pipe(Effect.map(SystemContext.combine)) const initialized = yield* SessionContextEpoch.initialize( db, - currentSystemContext, + loadSystemContext(sessionID), session.id, session.location, agentID, - ).pipe( - Effect.catchDefect((defect) => - defect instanceof SessionContextEpoch.AgentMismatch - ? Effect.die(new RetryTurn(promotion)) - : Effect.die(defect), - ), - ) + ).pipe(retryAgentMismatch(promotion)) const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { @@ -164,13 +172,14 @@ export const layer = Layer.effect( } const system = initialized ?? - (yield* SessionContextEpoch.prepare(db, events, currentSystemContext, session.id, session.location, agentID).pipe( - Effect.catchDefect((defect) => - defect instanceof SessionContextEpoch.AgentMismatch - ? Effect.die(new RetryTurn(undefined)) - : Effect.die(defect), - ), - )) + (yield* SessionContextEpoch.prepare( + db, + events, + loadSystemContext(sessionID), + session.id, + session.location, + agentID, + ).pipe(retryAgentMismatch(undefined))) const current = yield* getSession(sessionID) if ((yield* agents.resolve(current.agent))?.id !== agent?.id) return yield* runTurn(sessionID, undefined) const model = yield* models.resolve(current) @@ -272,7 +281,7 @@ export const layer = Layer.effect( }, Effect.scoped) const runTurn: ( sessionID: SessionSchema.ID, - promotion: "steer" | "queue" | undefined, + promotion: SessionInput.Delivery | undefined, ) => Effect.Effect = (sessionID, promotion) => runTurnAttempt(sessionID, promotion).pipe( Effect.catchDefect((defect) => @@ -288,7 +297,7 @@ export const layer = Layer.effect( const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) - let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined + let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 7cd9fbf9af1a..0b7b905754c3 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -11,7 +11,7 @@ import type { SessionSchema } from "./schema" import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" -import type { SystemContext } from "../system-context" +import type { SystemContext } from "../system-context/index" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 6eb7052539c3..87a05dc584df 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -3,7 +3,7 @@ export * as SessionStore from "./store" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { SessionContext } from "./context" +import { SessionHistory } from "./history" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" @@ -36,10 +36,10 @@ export const layer = Layer.effect( return row ? fromRow(row) : undefined }), context: Effect.fn("SessionStore.context")(function* (sessionID) { - return yield* SessionContext.load(db, sessionID) + return yield* SessionHistory.load(db, sessionID) }), runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { - return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq) + return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq) }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db diff --git a/packages/core/src/skill-guidance.ts b/packages/core/src/skill/guidance.ts similarity index 90% rename from packages/core/src/skill-guidance.ts rename to packages/core/src/skill/guidance.ts index 8c7cc3378083..1968d5aba9fb 100644 --- a/packages/core/src/skill-guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,12 +1,12 @@ -export * as SkillGuidance from "./skill-guidance" +export * as SkillGuidance from "./guidance" import { pathToFileURL } from "url" import { Context, Effect, Layer, Schema } from "effect" -import { AgentV2 } from "./agent" -import { PermissionV2 } from "./permission" -import { PluginBoot } from "./plugin/boot" -import { SkillV2 } from "./skill" -import { SystemContext } from "./system-context" +import { AgentV2 } from "../agent" +import { PermissionV2 } from "../permission" +import { PluginBoot } from "../plugin/boot" +import { SkillV2 } from "../skill" +import { SystemContext } from "../system-context/index" const Summary = Schema.Struct({ name: Schema.String, diff --git a/packages/core/src/system-context-builtins.ts b/packages/core/src/system-context/builtins.ts similarity index 85% rename from packages/core/src/system-context-builtins.ts rename to packages/core/src/system-context/builtins.ts index 8e74788e75d0..61666111ca2e 100644 --- a/packages/core/src/system-context-builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,10 +1,10 @@ -export * as SystemContextBuiltIns from "./system-context-builtins" +export * as SystemContextBuiltIns from "./builtins" import { DateTime, Effect, Layer, Schema } from "effect" -import { InstructionContext } from "./instruction-context" -import { Location } from "./location" -import { SystemContext } from "./system-context" -import { SystemContextRegistry } from "./system-context-registry" +import { Location } from "../location" +import { SystemContext } from "./index" +import { InstructionContext } from "../instruction-context" +import { SystemContextRegistry } from "./registry" const builtIns = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context/index.ts similarity index 99% rename from packages/core/src/system-context.ts rename to packages/core/src/system-context/index.ts index 739305bfebab..9fd4ca119f54 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context/index.ts @@ -1,4 +1,4 @@ -export * as SystemContext from "./system-context" +export * as SystemContext from "./index" import { Effect, Option, Schema } from "effect" diff --git a/packages/core/src/system-context-registry.ts b/packages/core/src/system-context/registry.ts similarity index 93% rename from packages/core/src/system-context-registry.ts rename to packages/core/src/system-context/registry.ts index 3e28f30c6e09..3fde33791520 100644 --- a/packages/core/src/system-context-registry.ts +++ b/packages/core/src/system-context/registry.ts @@ -1,7 +1,7 @@ -export * as SystemContextRegistry from "./system-context-registry" +export * as SystemContextRegistry from "./registry" import { Context, Effect, Layer, Ref, Scope } from "effect" -import { SystemContext } from "./system-context" +import { SystemContext } from "./index" export interface Contribution { readonly key: SystemContext.Key diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 2f04adca75b0..644bbb7c49d2 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -52,10 +52,7 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) ].join("\n") } -const notFound = (name: string, skills: ReadonlyArray) => - new ToolFailure({ - message: `Skill "${name}" not found. Available skills: ${skills.map((skill) => skill.name).join(", ") || "none"}`, - }) +const notFound = (name: string) => new ToolFailure({ message: `Skill "${name}" not found` }) export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -79,7 +76,7 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const current = yield* skills.list() const skill = current.find((skill) => skill.name === parameters.name) - if (!skill) return yield* notFound(parameters.name, current) + if (!skill) return yield* notFound(parameters.name) return yield* Effect.gen(function* () { yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] }) const directory = path.dirname(skill.location) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index bd2035ff3e2d..f21567f1f53f 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -8,7 +8,7 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 65a78f378729..58e90ac78918 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -20,9 +20,9 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" -import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SystemContext } from "@opencode-ai/core/system-context" -import { SkillGuidance } from "@opencode-ai/core/skill-guidance" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 19d92186fb09..1d9737bf2f70 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -42,8 +42,8 @@ import { } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" -import { SkillGuidance } from "@opencode-ai/core/skill-guidance" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AgentV2 } from "@opencode-ai/core/agent" @@ -148,8 +148,9 @@ const echo = Layer.effectDiscard( }), ), ).pipe(Layer.provide(registry)) +let modelResolveHook = Effect.void const models = SessionRunnerModel.layerWith((session) => - Effect.succeed(session.model?.id === "replacement" ? replacementModel : model), + modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : model)), ) const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" @@ -276,6 +277,7 @@ const setup = Effect.gen(function* () { systemRemoved = false systemUnavailable = false systemLoadHook = Effect.void + modelResolveHook = Effect.void skillBaselines.clear() responses = undefined streamFailure = undefined @@ -922,6 +924,51 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("applies an agent switch after the safe boundary to the next provider turn", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + skillBaselines.set(AgentV2.ID.make("build"), "Build skills") + skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") + let switched = false + modelResolveHook = Effect.suspend(() => { + if (switched) return Effect.void + switched = true + return events + .publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: "reviewer", + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + modelResolveHook = Effect.void + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context\n\nBuild skills"], + ["Initial context\n\nReviewer skills"], + ]) + expect( + yield* db + .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + ).toEqual({ replacementSeq: null }) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/skill-guidance.test.ts b/packages/core/test/skill/guidance.test.ts similarity index 97% rename from packages/core/test/skill-guidance.test.ts rename to packages/core/test/skill/guidance.test.ts index 139b0a442dbd..8fe266ecd158 100644 --- a/packages/core/test/skill-guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -6,9 +6,9 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" -import { SkillGuidance } from "@opencode-ai/core/skill-guidance" import { SystemContext } from "@opencode-ai/core/system-context" -import { it } from "./lib/effect" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { it } from "../lib/effect" const build = AgentV2.ID.make("build") const effect = new SkillV2.Info({ diff --git a/packages/core/test/system-context-builtins.test.ts b/packages/core/test/system-context/builtins.test.ts similarity index 97% rename from packages/core/test/system-context-builtins.test.ts rename to packages/core/test/system-context/builtins.test.ts index 6ec9ca41ee36..a74dd94866a3 100644 --- a/packages/core/test/system-context-builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -6,10 +6,10 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { AbsolutePath } from "@opencode-ai/core/schema" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextBuiltIns } from "@opencode-ai/core/system-context-builtins" -import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context/index.test.ts similarity index 99% rename from packages/core/test/system-context.test.ts rename to packages/core/test/system-context/index.test.ts index a20f8977b3e7..704843ba2355 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Schema } from "effect" import { SystemContext } from "@opencode-ai/core/system-context" -import { it } from "./lib/effect" +import { it } from "../lib/effect" const key = SystemContext.Key.make const stringContext = (input: { diff --git a/packages/core/test/system-context-registry.test.ts b/packages/core/test/system-context/registry.test.ts similarity index 98% rename from packages/core/test/system-context-registry.test.ts rename to packages/core/test/system-context/registry.test.ts index 9625276f1ef9..9f5e721fe0f6 100644 --- a/packages/core/test/system-context-registry.test.ts +++ b/packages/core/test/system-context/registry.test.ts @@ -1,8 +1,8 @@ import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Schema, Scope } from "effect" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" -import { testEffect } from "./lib/effect" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { testEffect } from "../lib/effect" const contribution = (key: string, text: string, sourceKey = key) => ({ key: SystemContext.Key.make(key), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 7e8c0e728ee8..b016e3e7bed0 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -141,7 +141,7 @@ describe("SkillTool", () => { sessionID, call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, }), - ).toEqual({ type: "error", value: 'Skill "missing" not found. Available skills: effect' }) + ).toEqual({ type: "error", value: 'Skill "missing" not found' }) }).pipe(Effect.provide(layer)) }), ), diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 7eee20ece4a8..bbbcadf4e003 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -779,7 +779,9 @@ Change: - Compose selected-agent, permission-filtered available-skill guidance with Location-wide System Context before Context Epoch admission. - Keep skill bodies behind the existing permission-checked `skill` tool and remove the unfiltered skill list from its Location-wide definition. -- Clear the active Context Epoch after an agent switch, fence epoch creation against the authoritative effective agent, and recheck the agent before provider dispatch. +- Stop missing-skill errors from enumerating the unfiltered Location-wide skill catalog. +- Request Context Epoch replacement after an agent switch, dynamically re-observe the effective agent during retries, and fence first-epoch creation against the authoritative effective agent. +- Group the System Context algebra, registry, and built-ins under `system-context/`; keep source producers and Context Epoch persistence with their owning Skill, instruction, and Session modules; rename projected conversation selection to Session History. - Add the canonical V1-to-V2 runtime-context parity checklist to `specs/v2/session.md`. Compatibility: diff --git a/specs/v2/session.md b/specs/v2/session.md index 167e9141235f..b85114f0e694 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -65,7 +65,7 @@ Client Runner System Context Registry C │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` -Model switches and completed compactions request lazy baseline replacement. An agent switch or Session move clears the epoch so a complete effective baseline must initialize before provider dispatch. Epoch creation is fenced against the authoritative Session Location and effective agent, preventing an old runner from recreating stale privileged context after a concurrent change. +Agent switches, model switches, and completed compactions request lazy baseline replacement. A switch admitted after the current safe provider-turn boundary applies to the next provider turn while leaving the already-prepared baseline durable. A Session move clears the epoch so the destination Location must initialize a complete baseline before another provider turn. Epoch creation is fenced against the authoritative Session Location and effective agent, preventing an old runner from recreating stale privileged context after a concurrent change. ```text Session Epoch From 2ae8622c63927b9b7a9e8cb923e8730d5a7ccbec Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:12:30 -0400 Subject: [PATCH 04/13] refactor(core): simplify v2 skill availability --- packages/core/src/permission.ts | 6 ++++-- packages/core/src/skill.ts | 12 +----------- packages/core/test/config/skill.test.ts | 1 - packages/core/test/skill.test.ts | 3 +-- packages/core/test/skill/guidance.test.ts | 17 +++++++++++++++++ packages/core/test/tool-skill.test.ts | 1 - 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 36a0574d3691..27550f76aedf 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -116,8 +116,10 @@ export function merge(...rulesets: Ruleset[]): Ruleset { } export function disabled(action: string, ruleset: Ruleset) { - const rule = ruleset.findLast((rule) => Wildcard.match(action, rule.action)) - return rule?.resource === "*" && rule.effect === "deny" + return ( + ruleset.findLast((rule) => Wildcard.match(action, rule.action) && (rule.resource === "*" || rule.effect !== "deny")) + ?.effect === "deny" + ) } export interface Interface { diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 23031e7c7037..259c8aff5e57 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -77,7 +77,6 @@ export interface Interface { readonly transform: State.Interface["transform"] readonly sources: () => Effect.Effect readonly list: () => Effect.Effect - readonly forAgent: (agent: AgentV2.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Skill") {} @@ -85,7 +84,6 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const agent = yield* AgentV2.Service const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service @@ -156,16 +154,8 @@ export const layer = Layer.effect( return state.get().sources }), list, - forAgent: Effect.fn("SkillV2.forAgent")(function* (id) { - const current = yield* agent.get(id) - if (!current) return [] - return available(yield* list(), current) - }), }) }), ) -export const locationLayer = layer.pipe( - Layer.provide(SkillDiscovery.defaultLayer), - Layer.provideMerge(AgentV2.locationLayer), -) +export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 830adcb279c5..52b9c0bb6662 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -51,7 +51,6 @@ describe("ConfigSkillPlugin.Plugin", () => { transform, sources: () => Effect.succeed(sources), list: () => Effect.succeed([]), - forAgent: () => Effect.succeed([]), }), ), ) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index ceadba065eb6..d0e01d0677ed 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -121,8 +121,7 @@ describe("SkillV2", () => { expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect(pulls).toBe(1) - expect(yield* skill.forAgent(AgentV2.ID.make("reviewer"))).toEqual([]) - expect(yield* skill.forAgent(AgentV2.ID.make("missing"))).toEqual([]) + expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([]) }), ), ), diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 8fe266ecd158..e4362bdf27b0 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -97,6 +97,23 @@ describe("SkillGuidance", () => { }).pipe(Effect.provide(layer(agent, () => [effect]))) }) + it.effect("omits guidance when a resource-specific denial follows the global denial", () => { + const agent = new AgentV2.Info({ + ...AgentV2.Info.empty(build), + permissions: [ + { action: "skill", resource: "*", effect: "deny" }, + { action: "skill", resource: "hidden", effect: "deny" }, + ], + }) + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + baseline: "", + snapshot: {}, + }) + }).pipe(Effect.provide(layer(agent, () => [effect]))) + }) + it.effect("retains specifically allowed skills after a global denial", () => { const agent = new AgentV2.Info({ ...AgentV2.Info.empty(build), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index b016e3e7bed0..4a6e3dc658c2 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -69,7 +69,6 @@ describe("SkillTool", () => { transform: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed([info]), - forAgent: () => Effect.die("unused"), }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) From a197246e4d44552ef01c4770d403ae30f8931f62 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:17:31 -0400 Subject: [PATCH 05/13] fix(core): fence context replacement by agent --- packages/core/src/session/context-epoch.ts | 49 +++++++++++++------- packages/core/test/session-runner.test.ts | 52 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 23fa94ae4e74..480913f2034f 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -85,7 +85,7 @@ const prepareOnce = Effect.fnUntraced(function* ( return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } if (result._tag === "ReplacementReady") { const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) - yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation) + yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) return { baseline: result.generation.baseline, baselineSeq: replacementSeq } } @@ -214,26 +214,45 @@ const insert = Effect.fnUntraced(function* ( const replace = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, + agent: AgentV2.ID, expectedRevision: number, baselineSeq: number, generation: SystemContext.Generation, ) { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - replacement_seq: null, - revision: expectedRevision + 1, - }) - .where( - and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)), + yield* db + .transaction( + () => + Effect.gen(function* () { + const selected = yield* db + .select({ agent: SessionTable.agent }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + replacement_seq: null, + revision: expectedRevision + 1, + }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + eq(SessionContextEpochTable.revision, expectedRevision), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new RevisionMismatch()) + }), + { behavior: "immediate" }, ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) }) const advance = Effect.fnUntraced(function* ( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1d9737bf2f70..cb4c2eeac28b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -26,6 +26,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" @@ -969,6 +970,57 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: AgentV2.ID.make("reviewer"), + }) + const context = (text: string) => + Effect.succeed( + SystemContext.make({ + key: systemContextKey, + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(text), + baseline: String, + update: (_previous, current) => current, + }), + ) + const location = (yield* session.get(sessionID)).location + + expect( + yield* SessionContextEpoch.prepare( + db, + events, + context("Stale build context"), + sessionID, + location, + AgentV2.defaultID, + ).pipe(Effect.catchDefect(Effect.succeed)), + ).toBeInstanceOf(SessionContextEpoch.AgentMismatch) + + expect( + yield* SessionContextEpoch.prepare( + db, + events, + context("Reviewer context"), + sessionID, + location, + AgentV2.ID.make("reviewer"), + ), + ).toMatchObject({ baseline: "Reviewer context" }) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup From 6bd2caff5e699af271f98596728bcb5a734617af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:25:23 -0400 Subject: [PATCH 06/13] fix(core): bind context epochs to agents --- CONTEXT.md | 4 +- .../migration.sql | 1 + .../snapshot.json | 2083 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 1 + .../20260605042240_add_context_epoch_agent.ts | 11 + packages/core/src/permission.ts | 7 - packages/core/src/session/context-epoch.ts | 16 +- packages/core/src/session/runner/index.ts | 2 + packages/core/src/session/runner/llm.ts | 16 +- packages/core/src/session/sql.ts | 2 + packages/core/src/skill/guidance.ts | 7 +- packages/core/src/tool/skill.ts | 2 +- packages/core/test/database-migration.test.ts | 3 + packages/core/test/session-runner.test.ts | 34 + packages/core/test/skill/guidance.test.ts | 18 + packages/core/test/tool-skill.test.ts | 2 +- specs/v2/schema-changelog.md | 6 +- specs/v2/session.md | 6 +- 18 files changed, 2190 insertions(+), 31 deletions(-) create mode 100644 packages/core/migration/20260605042240_add_context_epoch_agent/migration.sql create mode 100644 packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json create mode 100644 packages/core/src/database/migration/20260605042240_add_context_epoch_agent.ts diff --git a/CONTEXT.md b/CONTEXT.md index e2a57225684d..91e7a8416e20 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta _Avoid_: System update, system notification, raw text diff **Context Epoch**: -The span during which one initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. +The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. **Baseline System Context**: The full **System Context** rendered at the start of a **Context Epoch**. @@ -72,11 +72,13 @@ The point immediately before a provider call, after durable input promotion and - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only described skills permitted for that agent; skill bodies remain intentionally loaded through the permission-checked `skill` tool. - Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. +- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. +- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. diff --git a/packages/core/migration/20260605042240_add_context_epoch_agent/migration.sql b/packages/core/migration/20260605042240_add_context_epoch_agent/migration.sql new file mode 100644 index 000000000000..a9534b9b08f2 --- /dev/null +++ b/packages/core/migration/20260605042240_add_context_epoch_agent/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL; \ No newline at end of file diff --git a/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json b/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json new file mode 100644 index 000000000000..ec49baca3b09 --- /dev/null +++ b/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json @@ -0,0 +1,2083 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "d1bfa125-b81e-4c61-9b6e-e74abf6e488f", + "prevIds": [ + "40f7b9b8-83b4-4ea0-a59f-76a489679d88" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'build'", + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 299ff846a5f1..a7e9dd132efc 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -33,5 +33,6 @@ export const migrations = ( import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260604172448_event_sourced_session_input"), import("./migration/20260605003541_add_session_context_snapshot"), + import("./migration/20260605042240_add_context_epoch_agent"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260605042240_add_context_epoch_agent.ts b/packages/core/src/database/migration/20260605042240_add_context_epoch_agent.ts new file mode 100644 index 000000000000..cefd6ca037a4 --- /dev/null +++ b/packages/core/src/database/migration/20260605042240_add_context_epoch_agent.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260605042240_add_context_epoch_agent", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`agent\` text DEFAULT 'build' NOT NULL;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 27550f76aedf..bb75c7c9f5e5 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -115,13 +115,6 @@ export function merge(...rulesets: Ruleset[]): Ruleset { return rulesets.flat() } -export function disabled(action: string, ruleset: Ruleset) { - return ( - ruleset.findLast((rule) => Wildcard.match(action, rule.action) && (rule.resource === "*" || rule.effect !== "deny")) - ?.effect === "deny" - ) -} - export interface Interface { readonly ask: (input: AssertInput) => EffectRuntime.Effect readonly assert: (input: AssertInput) => EffectRuntime.Effect diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 480913f2034f..81dfe9a52622 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -19,6 +19,10 @@ type DatabaseService = Database.Interface["db"] class RevisionMismatch extends Error {} class LocationMismatch extends Error {} export class AgentMismatch extends Error {} +export class AgentReplacementBlocked extends Schema.TaggedErrorClass()( + "SessionContextEpoch.AgentReplacementBlocked", + { sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID }, +) {} const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => attempt().pipe( @@ -53,7 +57,10 @@ export function prepare( sessionID: SessionSchema.ID, location: Location.Ref, agent: AgentV2.ID, -): Effect.Effect { +): Effect.Effect< + Prepared, + SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked +> { return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( Effect.withSpan("SessionContextEpoch.prepare"), ) @@ -77,10 +84,13 @@ const prepareOnce = Effect.fnUntraced(function* ( const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), ) + const replacingAgent = stored.agent !== agent const result = - stored.replacement_seq === null + stored.replacement_seq === null && !replacingAgent ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot) + if (result._tag === "ReplacementBlocked" && replacingAgent) + return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent }) if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } if (result._tag === "ReplacementReady") { @@ -193,6 +203,7 @@ const insert = Effect.fnUntraced(function* ( .values({ session_id: sessionID, baseline: generation.baseline, + agent, snapshot: generation.snapshot, baseline_seq: baselineSeq, revision: 0, @@ -234,6 +245,7 @@ const replace = Effect.fnUntraced(function* ( .update(SessionContextEpochTable) .set({ baseline: generation.baseline, + agent, snapshot: generation.snapshot, baseline_seq: baselineSeq, replacement_seq: null, diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 819846aa29d8..85fd1f18e254 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -6,6 +6,7 @@ import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" +import type { SessionContextEpoch } from "../context-epoch" export class StepLimitExceededError extends Schema.TaggedErrorClass()( "SessionRunner.StepLimitExceededError", @@ -22,6 +23,7 @@ export type RunError = | ContextSnapshotDecodeError | StepLimitExceededError | SystemContext.InitializationBlocked + | SessionContextEpoch.AgentReplacementBlocked /** Runs one local continuation from already-recorded Session history. */ export interface Interface { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 29584e2119eb..a1e080e4d820 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -133,16 +133,8 @@ export const layer = Layer.effect( defect instanceof SessionContextEpoch.AgentMismatch ? Effect.die(new RetryTurn(promotion)) : Effect.die(defect), ) - const loadSystemContext = (sessionID: SessionSchema.ID) => - getSession(sessionID).pipe( - Effect.flatMap((session) => - Effect.all([systemContext.load(), agents.resolve(session.agent)], { concurrency: "unbounded" }), - ), - Effect.flatMap(([context, agent]) => - Effect.all([Effect.succeed(context), skillGuidance.load(agent?.id ?? AgentV2.defaultID)], { - concurrency: "unbounded", - }), - ), + const loadSystemContext = (agent: AgentV2.ID) => + Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe( Effect.map(SystemContext.combine), ) @@ -155,7 +147,7 @@ export const layer = Layer.effect( const agentID = agent?.id ?? AgentV2.defaultID const initialized = yield* SessionContextEpoch.initialize( db, - loadSystemContext(sessionID), + loadSystemContext(agentID), session.id, session.location, agentID, @@ -175,7 +167,7 @@ export const layer = Layer.effect( (yield* SessionContextEpoch.prepare( db, events, - loadSystemContext(sessionID), + loadSystemContext(agentID), session.id, session.location, agentID, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 0b7b905754c3..ca3d8e1b530d 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -12,6 +12,7 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { SystemContext } from "../system-context/index" +import { AgentV2 } from "../agent" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -169,6 +170,7 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), + agent: text().$type().notNull().default(AgentV2.defaultID), snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), replacement_seq: integer(), diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 1968d5aba9fb..cf1be3c9f039 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -51,8 +51,11 @@ export const layer = Layer.effect( load: Effect.fn("SkillGuidance.load")(function* (agentID) { yield* boot.wait() const agent = yield* agents.get(agentID) - if (!agent || PermissionV2.disabled("skill", agent.permissions)) return SystemContext.empty - const available = SkillV2.available(yield* skills.list(), agent) + if (!agent) return SystemContext.empty + const permitted = SkillV2.available(yield* skills.list(), agent) + if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny") + return SystemContext.empty + const available = permitted .flatMap((skill) => skill.description === undefined ? [] diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 644bbb7c49d2..38863e1134da 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -52,7 +52,7 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) ].join("\n") } -const notFound = (name: string) => new ToolFailure({ message: `Skill "${name}" not found` }) +const notFound = (name: string) => new ToolFailure({ message: `Unable to load skill ${name}` }) export const layer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 52c1e920ad47..80790e884035 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -67,6 +67,9 @@ describe("DatabaseMigration", () => { expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), ).toEqual({ name: "session_context_epoch" }) + expect( + yield* db.get(sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`), + ).toEqual({ name: "agent" }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cb4c2eeac28b..36884b86e958 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1021,6 +1021,40 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + skillBaselines.set(AgentV2.defaultID, "Build skills") + skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: AgentV2.ID.make("reviewer"), + }) + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + + requests.length = 0 + const blocked = yield* session.resume(sessionID).pipe(Effect.exit) + expect(Exit.isFailure(blocked)).toBe(true) + if (Exit.isFailure(blocked)) + expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked) + expect(requests).toHaveLength(0) + + systemUnavailable = false + yield* session.resume(sessionID) + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context\n\nReviewer skills"], + ]) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index e4362bdf27b0..f6838395e7cf 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -129,4 +129,22 @@ describe("SkillGuidance", () => { ) }).pipe(Effect.provide(layer(agent, () => [effect]))) }) + + it.effect("omits guidance when a specifically allowed skill is denied again", () => { + const agent = new AgentV2.Info({ + ...AgentV2.Info.empty(build), + permissions: [ + { action: "skill", resource: "*", effect: "deny" }, + { action: "skill", resource: "effect", effect: "allow" }, + { action: "skill", resource: "effect", effect: "deny" }, + ], + }) + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + baseline: "", + snapshot: {}, + }) + }).pipe(Effect.provide(layer(agent, () => [effect]))) + }) }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 4a6e3dc658c2..eca9cf4eeb65 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -140,7 +140,7 @@ describe("SkillTool", () => { sessionID, call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, }), - ).toEqual({ type: "error", value: 'Skill "missing" not found' }) + ).toEqual({ type: "error", value: "Unable to load skill missing" }) }).pipe(Effect.provide(layer)) }), ), diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index bbbcadf4e003..506deb07d63e 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -773,7 +773,8 @@ Compatibility: Affected schema: -- No database, synchronized event, public HTTP API, or generated SDK schema changes. +- Add `session_context_epoch.agent` so each durable baseline records its owning effective agent. +- No synchronized event, public HTTP API, or generated SDK schema changes. Change: @@ -781,9 +782,10 @@ Change: - Keep skill bodies behind the existing permission-checked `skill` tool and remove the unfiltered skill list from its Location-wide definition. - Stop missing-skill errors from enumerating the unfiltered Location-wide skill catalog. - Request Context Epoch replacement after an agent switch, dynamically re-observe the effective agent during retries, and fence first-epoch creation against the authoritative effective agent. +- Fence existing-epoch replacement against the authoritative effective agent and block cross-agent provider turns while replacement context is unavailable. - Group the System Context algebra, registry, and built-ins under `system-context/`; keep source producers and Context Epoch persistence with their owning Skill, instruction, and Session modules; rename projected conversation selection to Session History. - Add the canonical V1-to-V2 runtime-context parity checklist to `specs/v2/session.md`. Compatibility: -- Existing experimental V2 Context Epochs reconcile the new source at the next safe provider-turn boundary. +- Existing Context Epoch rows backfill the default `build` agent and reconcile to another selected agent at the next safe provider-turn boundary. diff --git a/specs/v2/session.md b/specs/v2/session.md index b85114f0e694..16bf8bd987c6 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -39,7 +39,7 @@ Projected hosted tools preserve call-side and settlement-side provider metadata ## Context Epochs -V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. +V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one effective agent, one immutable baseline, and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. @@ -65,7 +65,7 @@ Client Runner System Context Registry C │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` -Agent switches, model switches, and completed compactions request lazy baseline replacement. A switch admitted after the current safe provider-turn boundary applies to the next provider turn while leaving the already-prepared baseline durable. A Session move clears the epoch so the destination Location must initialize a complete baseline before another provider turn. Epoch creation is fenced against the authoritative Session Location and effective agent, preventing an old runner from recreating stale privileged context after a concurrent change. +Agent switches, model switches, and completed compactions request lazy baseline replacement. A switch admitted after the current safe provider-turn boundary applies to the next provider turn while leaving the already-prepared baseline durable. Before another cross-agent provider turn, the replacement must complete; unavailable admitted context blocks instead of exposing the prior agent's privileged baseline. A Session move clears the epoch so the destination Location must initialize a complete baseline before another provider turn. Epoch creation and replacement are fenced against the authoritative Session Location/effective agent and the epoch revision, preventing stale or ABA-observed context from becoming durable. ```text Session Epoch @@ -109,7 +109,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | | Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | | Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe provider-turn boundary. | -| Durable Context Source | Selected-agent available skill guidance and skill-body loading | complete | None. | +| Durable Context Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body loading are permission-filtered; remove globally denied skill definitions during request-time tool materialization. | | Per-turn request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | | Per-turn request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | | Per-turn request assembly | Provider/model-specific base instructions | missing | Select the provider-family baseline unless the effective agent overrides it. | From 6a3ede12d14f49035f66e8c92f03e46695098934 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:26:39 -0400 Subject: [PATCH 07/13] test(core): verify context epoch agent backfill --- packages/core/test/database-migration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 80790e884035..9dfa7802237e 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -68,8 +68,10 @@ describe("DatabaseMigration", () => { yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), ).toEqual({ name: "session_context_epoch" }) expect( - yield* db.get(sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`), - ).toEqual({ name: "agent" }) + yield* db.get( + sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`, + ), + ).toEqual({ name: "agent", dflt_value: "'build'" }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( From 81c022e54b62de09bcd20c34f2b8fdbab3474e2e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:32:46 -0400 Subject: [PATCH 08/13] fix(core): bind provider turn policy --- CONTEXT.md | 3 +- packages/core/src/permission.ts | 15 +++- packages/core/src/session/context-epoch.ts | 38 ++++++++- packages/core/src/session/runner/llm.ts | 8 +- packages/core/src/skill/guidance.ts | 7 +- packages/core/src/tool/registry.ts | 7 +- packages/core/test/database-migration.test.ts | 21 +++++ packages/core/test/permission.test.ts | 23 +++++ packages/core/test/session-runner.test.ts | 85 +++++++++++++++++++ packages/core/test/skill/guidance.test.ts | 2 - packages/core/test/tool-skill.test.ts | 13 ++- specs/v2/schema-changelog.md | 2 + specs/v2/session.md | 46 +++++----- 13 files changed, 225 insertions(+), 45 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 91e7a8416e20..b5de38067deb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,9 +70,10 @@ The point immediately before a provider call, after durable input promotion and - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. -- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only described skills permitted for that agent; skill bodies remain intentionally loaded through the permission-checked `skill` tool. +- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. - Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. - A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. +- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bb75c7c9f5e5..dd04327ae46a 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -36,6 +36,7 @@ export type Source = typeof Source.Type export const Request = Schema.Struct({ id: ID, sessionID: SessionV2.ID, + agent: AgentV2.ID.pipe(Schema.optional), action: Schema.String, resources: Schema.Array(Schema.String), save: Schema.Array(Schema.String).pipe(Schema.optional), @@ -50,6 +51,7 @@ export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ id: ID.pipe(Schema.optional), sessionID: SessionV2.ID, + agent: AgentV2.ID.pipe(Schema.optional), action: Schema.String, resources: Schema.Array(Schema.String), save: Schema.Array(Schema.String).pipe(Schema.optional), @@ -159,10 +161,14 @@ export const layer = Layer.effect( ) }) - const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) { + const configured = EffectRuntime.fn("PermissionV2.configured")(function* ( + sessionID: SessionV2.ID, + agentID?: AgentV2.ID, + ) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) - return (yield* agents.resolve(session.agent))?.permissions ?? missingAgentPermissions + const agent = agentID ? yield* agents.get(agentID) : yield* agents.resolve(session.agent) + return agent?.permissions ?? missingAgentPermissions }) function denied(input: AssertInput, rules: Ruleset) { @@ -174,7 +180,7 @@ export const layer = Layer.effect( } const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) { - const rules = yield* configured(input.sessionID) + const rules = yield* configured(input.sessionID, input.agent) if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) @@ -186,6 +192,7 @@ export const layer = Layer.effect( return { id: input.id ?? ID.create(), sessionID: input.sessionID, + agent: input.agent, action: input.action, resources: input.resources, save: input.save, @@ -281,7 +288,7 @@ export const layer = Layer.effect( const rememberedRules = yield* savedRules() for (const [id, item] of pending) { const input = { ...item.request } - const rules = yield* configured(item.request.sessionID).pipe( + const rules = yield* configured(item.request.sessionID, item.request.agent).pipe( EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), ) if (!rules) continue diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 81dfe9a52622..b4400658e132 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -89,10 +89,14 @@ const prepareOnce = Effect.fnUntraced(function* ( stored.replacement_seq === null && !replacingAgent ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot) - if (result._tag === "ReplacementBlocked" && replacingAgent) + if (result._tag === "ReplacementBlocked" && replacingAgent) { + yield* fence(db, sessionID, agent, stored.revision) return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent }) - if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") + } + if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { + yield* fence(db, sessionID, agent, stored.revision) return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + } if (result._tag === "ReplacementReady") { const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) @@ -267,6 +271,36 @@ const replace = Effect.fnUntraced(function* ( .pipe(Effect.orDie) }) +const fence = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + agent: AgentV2.ID, + expectedRevision: number, +) { + yield* db + .transaction( + () => + Effect.gen(function* () { + const selected = yield* db + .select({ agent: SessionTable.agent }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + const epoch = yield* db + .select({ revision: SessionContextEpochTable.revision }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!epoch || epoch.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a1e080e4d820..14c6bdb72159 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -173,8 +173,8 @@ export const layer = Layer.effect( agentID, ).pipe(retryAgentMismatch(undefined))) const current = yield* getSession(sessionID) - if ((yield* agents.resolve(current.agent))?.id !== agent?.id) return yield* runTurn(sessionID, undefined) - const model = yield* models.resolve(current) + if ((yield* agents.resolve(current.agent))?.id !== agent?.id) return yield* Effect.die(new RetryTurn(undefined)) + const model = yield* models.resolve(session) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, @@ -190,7 +190,7 @@ export const layer = Layer.effect( model: { id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), - ...(current.model?.variant === undefined ? {} : { variant: current.model.variant }), + ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }, }) const withPublication = Semaphore.makeUnsafe(1).withPermit @@ -201,7 +201,7 @@ export const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return needsContinuation = true - yield* tools.settle({ sessionID: session.id, call: event }).pipe( + yield* tools.settle({ sessionID: session.id, agent: agentID, call: event }).pipe( Effect.catchCause((cause) => { if (isQuestionRejected(cause)) return Effect.failCause(cause) return Effect.succeed({ diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index cf1be3c9f039..e9fbadfffac6 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,6 +1,5 @@ export * as SkillGuidance from "./guidance" -import { pathToFileURL } from "url" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -11,7 +10,6 @@ import { SystemContext } from "../system-context/index" const Summary = Schema.Struct({ name: Schema.String, description: Schema.String, - location: Schema.String, }) type Summary = typeof Summary.Type @@ -27,7 +25,6 @@ const render = (skills: ReadonlyArray) => " ", ` ${skill.name}`, ` ${skill.description}`, - ` ${skill.location}`, " ", ]), "", @@ -57,9 +54,7 @@ export const layer = Layer.effect( return SystemContext.empty const available = permitted .flatMap((skill) => - skill.description === undefined - ? [] - : [{ name: skill.name, description: skill.description, location: pathToFileURL(skill.location).href }], + skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }], ) .toSorted((a, b) => a.name.localeCompare(b.name)) return SystemContext.make({ diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 056f65d31730..52dd44675e89 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -18,9 +18,11 @@ import { State } from "../state" import { SessionSchema } from "../session/schema" import type { SessionV2 } from "../session" import { ApplicationTools } from "./application-tools" +import { AgentV2 } from "../agent" export type ExecuteInput = { readonly sessionID: SessionSchema.ID + readonly agent?: AgentV2.ID readonly call: ToolCall } @@ -37,7 +39,7 @@ export type ExecuteInput = { export type Invocation = ExecuteInput & { readonly source?: PermissionV2.Source readonly assertPermission: ( - input: Omit, + input: Omit, ) => Effect.Effect } @@ -129,7 +131,8 @@ export const layer = Layer.effect( const invocation = (input: ExecuteInput): Invocation => ({ ...input, // Source needs the durable owning assistant message ID, which the registry does not receive yet. - assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }), + assertPermission: (request) => + permission.assert({ ...request, sessionID: input.sessionID, ...(input.agent ? { agent: input.agent } : {}) }), }) const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* ( diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 9dfa7802237e..7daff4e5b269 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -12,6 +12,7 @@ import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510 import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" +import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -91,6 +92,26 @@ describe("DatabaseMigration", () => { ) }) + test("backfills existing Context Epoch rows to the build agent", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL, replacement_seq integer, revision integer DEFAULT 0 NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('ses_existing', 'baseline', '{}', 0)`, + ) + + yield* DatabaseMigration.applyOnly(db, [contextEpochAgentMigration]) + + expect(yield* db.get(sql`SELECT agent FROM session_context_epoch WHERE session_id = 'ses_existing'`)).toEqual({ + agent: "build", + }) + }), + ) + }) + test("resets beta history and rebuilds event-sourced Session input storage", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 00b0cf9af69c..9ac7c54e772f 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -126,6 +126,29 @@ describe("PermissionV2", () => { }), ) + it.effect("evaluates against an explicit provider-turn agent", () => + Effect.gen(function* () { + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) + }), + ) + const service = yield* PermissionV2.Service + + expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) + expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.permissions = [] + }), + ) + expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "ask" }) + expect(yield* service.get(PermissionV2.ID.create("per_test"))).toMatchObject({ agent: "reviewer" }) + }), + ) + it.effect("allows and denies from explicit rules without asking", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 36884b86e958..d6266871c0e4 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -970,6 +970,91 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("applies a model switch after the safe boundary to the next provider turn", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + let switched = false + modelResolveHook = Effect.suspend(() => { + if (switched) return Effect.void + switched = true + return events + .publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + modelResolveHook = Effect.void + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Replacement context"], + ]) + }), + ) + + it.effect("fences an unchanged epoch read across an agent ABA replacement request", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + let switched = false + systemLoadHook = Effect.suspend(() => { + if (switched) return Effect.void + switched = true + return events + .publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + agent: AgentV2.ID.make("reviewer"), + }) + .pipe( + Effect.andThen( + events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(2), + agent: AgentV2.defaultID, + }), + ), + Effect.asVoid, + ) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + + requests.length = 0 + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect( + yield* db + .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + ).toEqual({ replacementSeq: null }) + }), + ) + it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index f6838395e7cf..2da8375e3a1a 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -1,5 +1,4 @@ import path from "path" -import { pathToFileURL } from "url" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" @@ -57,7 +56,6 @@ describe("SkillGuidance", () => { " ", " effect", " Build applications with Effect", - ` ${pathToFileURL(effect.location).href}`, " ", "", ].join("\n"), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index eca9cf4eeb65..29dd6ab07d04 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -39,6 +39,7 @@ describe("SkillTool", () => { content: "# Effect\n\nGuidance", } const assertions: PermissionV2.AssertInput[] = [] + let deny = false const truncations: ToolOutputStore.TruncateInput[] = [] let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect => Effect.succeed({ content: input.content, truncated: false }) @@ -55,7 +56,10 @@ describe("SkillTool", () => { const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ - assert: (input) => Effect.sync(() => assertions.push(input)), + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void), + ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), @@ -141,6 +145,13 @@ describe("SkillTool", () => { call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, }), ).toEqual({ type: "error", value: "Unable to load skill missing" }) + deny = true + expect( + yield* registry.execute({ + sessionID, + call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, + }), + ).toEqual({ type: "error", value: "Unable to load skill effect" }) }).pipe(Effect.provide(layer)) }), ), diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 506deb07d63e..51f807e31676 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -781,6 +781,8 @@ Change: - Compose selected-agent, permission-filtered available-skill guidance with Location-wide System Context before Context Epoch admission. - Keep skill bodies behind the existing permission-checked `skill` tool and remove the unfiltered skill list from its Location-wide definition. - Stop missing-skill errors from enumerating the unfiltered Location-wide skill catalog. +- Bind local tool authorization and pending permission requests to the provider turn's effective agent. +- Keep absolute skill locations out of available-skill guidance; expose body and location only through the permission-checked `skill` tool. - Request Context Epoch replacement after an agent switch, dynamically re-observe the effective agent during retries, and fence first-epoch creation against the authoritative effective agent. - Fence existing-epoch replacement against the authoritative effective agent and block cross-agent provider turns while replacement context is unavailable. - Group the System Context algebra, registry, and built-ins under `system-context/`; keep source producers and Context Epoch persistence with their owning Skill, instruction, and Session modules; rename projected conversation selection to Session History. diff --git a/specs/v2/session.md b/specs/v2/session.md index 16bf8bd987c6..df633d313691 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -103,29 +103,29 @@ This is the canonical checklist for model-visible runtime context still needed b Status: `complete` is usable in the native V2 path, `partial` covers only part of V1 behavior, and `missing` has no native V2 equivalent. -| Boundary | Behavior | Status | Remaining V2 work | -| -------------------------- | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Durable Context Source | Environment facts and host-local date | partial | Add selected provider/model identity without making model selection a stale Location-wide value. | -| Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | -| Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | -| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe provider-turn boundary. | -| Durable Context Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body loading are permission-filtered; remove globally denied skill definitions during request-time tool materialization. | -| Per-turn request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | -| Per-turn request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | -| Per-turn request assembly | Provider/model-specific base instructions | missing | Select the provider-family baseline unless the effective agent overrides it. | -| Per-turn request assembly | Policy-filtered built-in, MCP, plugin, and structured-output tools | partial | Materialize definitions for the effective agent and request. | -| Per-turn request assembly | Per-prompt system text and tool overrides | missing | Design admission and durable replay semantics before exposing them. | -| Per-turn request assembly | Steering, plan/build-switch, and final-step reminders | missing | Add only reminders whose behavior remains part of V2. | -| Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | -| Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | -| Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | -| Per-turn request assembly | Automatic/context-pressure compaction | partial | V2 replays completed compactions and replaces epochs but cannot initiate compaction. | -| Prompt/reference expansion | Durable typed prompt attachments | complete | None. | -| Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. | -| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. | -| Prompt/reference expansion | Agent-reference expansion | missing | Produce permission-aware model-visible task guidance. | -| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. | -| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. | +| Boundary | Behavior | Status | Remaining V2 work | +| -------------------------- | ------------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Durable Context Source | Environment facts and host-local date | partial | Add selected provider/model identity without making model selection a stale Location-wide value. | +| Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | +| Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | +| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe provider-turn boundary. | +| Durable Context Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization. | +| Per-turn request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | +| Per-turn request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | +| Per-turn request assembly | Provider/model-specific base instructions | missing | Select the provider-family baseline unless the effective agent overrides it. | +| Per-turn request assembly | Policy-filtered built-in, MCP, plugin, and structured-output tools | partial | Materialize definitions for the effective agent and request. | +| Per-turn request assembly | Per-prompt system text and tool overrides | missing | Design admission and durable replay semantics before exposing them. | +| Per-turn request assembly | Steering, plan/build-switch, and final-step reminders | missing | Add only reminders whose behavior remains part of V2. | +| Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | +| Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | +| Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | +| Per-turn request assembly | Automatic/context-pressure compaction | partial | V2 replays completed compactions and replaces epochs but cannot initiate compaction. | +| Prompt/reference expansion | Durable typed prompt attachments | complete | None. | +| Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. | +| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. | +| Prompt/reference expansion | Agent-reference expansion | missing | Produce permission-aware model-visible task guidance. | +| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. | +| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. | Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. From cbd2bcc5b7cf2d8d80d4db4e4b29d18354e51d52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:36:12 -0400 Subject: [PATCH 09/13] refactor(core): centralize context policy fences --- packages/core/src/permission.ts | 16 +++++------- packages/core/src/session/context-epoch.ts | 30 ++++++++++++---------- packages/core/src/tool/skill.ts | 13 +++------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index dd04327ae46a..ca33f797310a 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -33,8 +33,7 @@ export const Source = Schema.Union([ ]).annotate({ identifier: "PermissionV2.Source" }) export type Source = typeof Source.Type -export const Request = Schema.Struct({ - id: ID, +const RequestFields = { sessionID: SessionV2.ID, agent: AgentV2.ID.pipe(Schema.optional), action: Schema.String, @@ -42,6 +41,11 @@ export const Request = Schema.Struct({ save: Schema.Array(Schema.String).pipe(Schema.optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), source: Source.pipe(Schema.optional), +} + +export const Request = Schema.Struct({ + id: ID, + ...RequestFields, }).annotate({ identifier: "PermissionV2.Request" }) export type Request = typeof Request.Type @@ -50,13 +54,7 @@ export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ id: ID.pipe(Schema.optional), - sessionID: SessionV2.ID, - agent: AgentV2.ID.pipe(Schema.optional), - action: Schema.String, - resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + ...RequestFields, }).annotate({ identifier: "PermissionV2.AssertInput" }) export type AssertInput = typeof AssertInput.Type diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index b4400658e132..48cf17dafeda 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -144,6 +144,20 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic .pipe(Effect.orDie) }) +const requireEffectiveAgent = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + agent: AgentV2.ID, +) { + const selected = yield* db + .select({ agent: SessionTable.agent }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) +}) + export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -238,13 +252,7 @@ const replace = Effect.fnUntraced(function* ( .transaction( () => Effect.gen(function* () { - const selected = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + yield* requireEffectiveAgent(db, sessionID, agent) const updated = yield* db .update(SessionContextEpochTable) .set({ @@ -281,13 +289,7 @@ const fence = Effect.fnUntraced(function* ( .transaction( () => Effect.gen(function* () { - const selected = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + yield* requireEffectiveAgent(db, sessionID, agent) const epoch = yield* db .select({ revision: SessionContextEpochTable.revision }) .from(SessionContextEpochTable) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 38863e1134da..000567fea3b7 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -52,7 +52,8 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) ].join("\n") } -const notFound = (name: string) => new ToolFailure({ message: `Unable to load skill ${name}` }) +const unableToLoad = (name: string, error?: unknown) => + new ToolFailure({ message: `Unable to load skill ${name}`, error }) export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -76,7 +77,7 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const current = yield* skills.list() const skill = current.find((skill) => skill.name === parameters.name) - if (!skill) return yield* notFound(parameters.name) + if (!skill) return yield* unableToLoad(parameters.name) return yield* Effect.gen(function* () { yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] }) const directory = path.dirname(skill.location) @@ -96,13 +97,7 @@ export const layer = Layer.effectDiscard( truncated: output.truncated, ...(output.truncated ? { resource: output.resource } : {}), } - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ message: `Unable to load skill ${parameters.name}`, error: Cause.squash(cause) }), - ), - ), - ) + }).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause))))) }), }), ) From 9c2367e5bad811bd7ad3b01ad519a90f995a468d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:36:56 -0400 Subject: [PATCH 10/13] refactor(core): simplify context epoch fences --- packages/core/src/session/context-epoch.ts | 23 ++++++++-------------- packages/core/src/session/runner/llm.ts | 4 +++- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 48cf17dafeda..464206ee508a 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -285,22 +285,15 @@ const fence = Effect.fnUntraced(function* ( agent: AgentV2.ID, expectedRevision: number, ) { - yield* db - .transaction( - () => - Effect.gen(function* () { - yield* requireEffectiveAgent(db, sessionID, agent) - const epoch = yield* db - .select({ revision: SessionContextEpochTable.revision }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!epoch || epoch.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) - }), - { behavior: "immediate" }, - ) + const current = yield* db + .select({ agent: SessionTable.agent, revision: SessionContextEpochTable.revision }) + .from(SessionContextEpochTable) + .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() .pipe(Effect.orDie) + if (!current || AgentV2.effectiveID(current.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) }) const advance = Effect.fnUntraced(function* ( diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 14c6bdb72159..c1674d94a59b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -277,7 +277,9 @@ export const layer = Layer.effect( ) => Effect.Effect = (sessionID, promotion) => runTurnAttempt(sessionID, promotion).pipe( Effect.catchDefect((defect) => - defect instanceof RetryTurn ? runTurn(sessionID, defect.promotion) : Effect.die(defect), + defect instanceof RetryTurn + ? Effect.yieldNow.pipe(Effect.andThen(runTurn(sessionID, defect.promotion))) + : Effect.die(defect), ), ) From 6d8873dd68c17df66ba09f31362572aa696064c9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:42:12 -0400 Subject: [PATCH 11/13] fix(core): isolate skill support files --- packages/core/src/permission.ts | 14 +++++++------- packages/core/src/tool/skill.ts | 11 +++++++---- packages/core/test/permission.test.ts | 2 +- packages/core/test/tool-skill.test.ts | 24 +++++++++++++++++++++++- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index ca33f797310a..fb553bf03288 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -35,7 +35,6 @@ export type Source = typeof Source.Type const RequestFields = { sessionID: SessionV2.ID, - agent: AgentV2.ID.pipe(Schema.optional), action: Schema.String, resources: Schema.Array(Schema.String), save: Schema.Array(Schema.String).pipe(Schema.optional), @@ -55,6 +54,7 @@ export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ id: ID.pipe(Schema.optional), ...RequestFields, + agent: AgentV2.ID.pipe(Schema.optional), }).annotate({ identifier: "PermissionV2.AssertInput" }) export type AssertInput = typeof AssertInput.Type @@ -128,6 +128,7 @@ export class Service extends Context.Service()("@opencode/v2 interface Pending { readonly request: Request + readonly agent?: AgentV2.ID readonly deferred: Deferred.Deferred } @@ -190,7 +191,6 @@ export const layer = Layer.effect( return { id: input.id ?? ID.create(), sessionID: input.sessionID, - agent: input.agent, action: input.action, resources: input.resources, save: input.save, @@ -199,11 +199,11 @@ export const layer = Layer.effect( } } - const create = (request: Request) => + const create = (request: Request, agent?: AgentV2.ID) => EffectRuntime.uninterruptible( EffectRuntime.gen(function* () { const deferred = yield* Deferred.make() - const item = { request, deferred } + const item = { request, agent, deferred } if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) pending.set(request.id, item) yield* events @@ -216,7 +216,7 @@ export const layer = Layer.effect( const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { const result = yield* evaluateInput(input) const value = request(input) - if (result.effect === "ask") yield* create(value) + if (result.effect === "ask") yield* create(value, input.agent) return { id: value.id, effect: result.effect } }) @@ -230,7 +230,7 @@ export const layer = Layer.effect( }) } if (result.effect === "allow") return - const item = yield* create(request(input)) + const item = yield* create(request(input), input.agent) return yield* restore(Deferred.await(item.deferred)).pipe( EffectRuntime.ensuring( EffectRuntime.sync(() => { @@ -286,7 +286,7 @@ export const layer = Layer.effect( const rememberedRules = yield* savedRules() for (const [id, item] of pending) { const input = { ...item.request } - const rules = yield* configured(item.request.sessionID, item.request.agent).pipe( + const rules = yield* configured(item.request.sessionID, item.agent).pipe( EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), ) if (!rules) continue diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 000567fea3b7..3725e6a3bd68 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -81,10 +81,13 @@ export const layer = Layer.effectDiscard( return yield* Effect.gen(function* () { yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] }) const directory = path.dirname(skill.location) - const files = (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) - .filter((file) => path.basename(file) !== "SKILL.md") - .toSorted() - .slice(0, FILE_LIMIT) + const files = + path.basename(skill.location) === "SKILL.md" + ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] const output = yield* resources.truncate({ sessionID, toolCallID: call.id, diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 9ac7c54e772f..6e91da1af620 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -145,7 +145,7 @@ describe("PermissionV2", () => { }), ) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "ask" }) - expect(yield* service.get(PermissionV2.ID.create("per_test"))).toMatchObject({ agent: "reviewer" }) + expect(yield* service.get(PermissionV2.ID.create("per_test"))).not.toHaveProperty("agent") }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 29dd6ab07d04..35a2770a4bae 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -38,6 +38,7 @@ describe("SkillTool", () => { location: AbsolutePath.make(location), content: "# Effect\n\nGuidance", } + let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false const truncations: ToolOutputStore.TruncateInput[] = [] @@ -72,7 +73,7 @@ describe("SkillTool", () => { SkillV2.Service.of({ transform: () => Effect.die("unused"), sources: () => Effect.die("unused"), - list: () => Effect.succeed([info]), + list: () => Effect.succeed(current), }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) @@ -152,6 +153,27 @@ describe("SkillTool", () => { call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) + deny = false + const flat = new SkillV2.Info({ + name: "public", + description: "Public guidance", + location: AbsolutePath.make(path.join(tmp.path, "public.md")), + content: "Public", + }) + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(flat.location, "public"), + fs.writeFile(path.join(tmp.path, "secret.md"), "secret"), + ]), + ) + current = [flat] + truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) + expect( + yield* registry.execute({ + sessionID, + call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, + }), + ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) }).pipe(Effect.provide(layer)) }), ), From 608a203949a4ae1bb0bdd4994fdad0985443d04a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 00:48:21 -0400 Subject: [PATCH 12/13] fix(core): fence provider dispatch by epoch --- packages/core/src/session/context-epoch.ts | 27 ++++++++++++++++++---- packages/core/src/session/runner/llm.ts | 12 +++++++++- packages/core/test/session-runner.test.ts | 21 ++++------------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 464206ee508a..f0fb74098de9 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -36,6 +36,7 @@ const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect interface Prepared { readonly baseline: string readonly baselineSeq: number + readonly revision: number } export function initialize( @@ -78,7 +79,7 @@ const prepareOnce = Effect.fnUntraced(function* ( if (!stored) { const generation = yield* SystemContext.initialize(value) const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq } + return { baseline: generation.baseline, baselineSeq, revision: 0 } } const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( @@ -95,12 +96,12 @@ const prepareOnce = Effect.fnUntraced(function* ( } if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { yield* fence(db, sessionID, agent, stored.revision) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision } } if (result._tag === "ReplacementReady") { const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq: replacementSeq } + return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 } } yield* events.publish( @@ -108,7 +109,7 @@ const prepareOnce = Effect.fnUntraced(function* ( { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 } }) const initializeOnce = Effect.fnUntraced(function* ( @@ -121,7 +122,7 @@ const initializeOnce = Effect.fnUntraced(function* ( if (yield* exists(db, sessionID)) return const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq } + return { baseline: generation.baseline, baselineSeq, revision: 0 } }) const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -296,6 +297,22 @@ const fence = Effect.fnUntraced(function* ( if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) }) +export const current = Effect.fn("SessionContextEpoch.current")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + agent: AgentV2.ID, + revision: number, +) { + const value = yield* db + .select({ agent: SessionTable.agent, revision: SessionContextEpochTable.revision }) + .from(SessionContextEpochTable) + .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return value !== undefined && AgentV2.effectiveID(value.agent) === agent && value.revision === revision +}) + const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index c1674d94a59b..a9a033331ee8 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -133,6 +133,13 @@ export const layer = Layer.effect( defect instanceof SessionContextEpoch.AgentMismatch ? Effect.die(new RetryTurn(promotion)) : Effect.die(defect), ) + const sameModel = (left: ModelV2.Ref | undefined, right: ModelV2.Ref | undefined) => + left === right || + (left !== undefined && + right !== undefined && + left.id === right.id && + left.providerID === right.providerID && + left.variant === right.variant) const loadSystemContext = (agent: AgentV2.ID) => Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe( Effect.map(SystemContext.combine), @@ -173,7 +180,8 @@ export const layer = Layer.effect( agentID, ).pipe(retryAgentMismatch(undefined))) const current = yield* getSession(sessionID) - if ((yield* agents.resolve(current.agent))?.id !== agent?.id) return yield* Effect.die(new RetryTurn(undefined)) + if ((yield* agents.resolve(current.agent))?.id !== agent?.id || !sameModel(current.model, session.model)) + return yield* Effect.die(new RetryTurn(undefined)) const model = yield* models.resolve(session) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ @@ -195,6 +203,8 @@ export const layer = Layer.effect( }) const withPublication = Semaphore.makeUnsafe(1).withPermit const publish = (event: LLMEvent) => withPublication(publisher.publish(event)) + if (!(yield* SessionContextEpoch.current(db, session.id, agentID, system.revision))) + return yield* Effect.die(new RetryTurn(undefined)) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d6266871c0e4..42da4a95ddc1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -925,7 +925,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("applies an agent switch after the safe boundary to the next provider turn", () => + it.effect("retries an agent switch before the final provider-dispatch boundary", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -951,12 +951,7 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - modelResolveHook = Effect.void - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nBuild skills"], ["Initial context\n\nReviewer skills"], ]) expect( @@ -970,7 +965,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("applies a model switch after the safe boundary to the next provider turn", () => + it.effect("retries a model switch before the final provider-dispatch boundary", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -993,16 +988,8 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - modelResolveHook = Effect.void - systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - - expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context"], - ["Replacement context"], - ]) + expect(requests.map((request) => request.model)).toEqual([replacementModel]) + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]]) }), ) From 3106bae645f11e747067a896ec519bb03e7cdfdf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 11:06:05 -0400 Subject: [PATCH 13/13] fix(core): align skill guidance with default agents --- packages/core/src/agent.ts | 15 ++++++++- packages/core/src/permission.ts | 2 +- packages/core/src/session/context-epoch.ts | 31 +++++++++++-------- packages/core/src/session/runner/llm.ts | 33 ++++++++------------ packages/core/src/skill/guidance.ts | 7 ++--- packages/core/test/session-runner.test.ts | 7 ++--- packages/core/test/skill/guidance.test.ts | 36 +++++++++++++--------- 7 files changed, 74 insertions(+), 57 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index e2a1f4407f94..3e5987297353 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -11,7 +11,6 @@ import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type export const defaultID = ID.make("build") -export const effectiveID = (id: string | null | undefined) => ID.make(id ?? defaultID) export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), @@ -44,6 +43,11 @@ export class Info extends Schema.Class("AgentV2.Info")({ } } +export interface Selection { + readonly id: ID + readonly info: Info | undefined +} + type Data = { agents: Map default?: ID @@ -63,6 +67,7 @@ export interface Interface { readonly get: (id: ID) => Effect.Effect readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect + readonly select: (id?: ID | string) => Effect.Effect readonly all: () => Effect.Effect } @@ -122,6 +127,14 @@ export const layer = Layer.effect( if (id !== undefined) return state.get().agents.get(ID.make(id)) return selectedDefault() }), + select: Effect.fn("AgentV2.select")(function* (id) { + if (id !== undefined) { + const selected = ID.make(id) + return { id: selected, info: state.get().agents.get(selected) } + } + const info = selectedDefault() + return { id: info?.id ?? defaultID, info } + }), all: Effect.fn("AgentV2.all")(function* () { return Array.fromIterable(state.get().agents.values()) }), diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index fb553bf03288..bbfc6014e8ce 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -166,7 +166,7 @@ export const layer = Layer.effect( ) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) - const agent = agentID ? yield* agents.get(agentID) : yield* agents.resolve(session.agent) + const agent = yield* agents.resolve(agentID ?? session.agent) return agent?.permissions ?? missingAgentPermissions }) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index f0fb74098de9..1fb8df92e6e1 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -58,10 +58,7 @@ export function prepare( sessionID: SessionSchema.ID, location: Location.Ref, agent: AgentV2.ID, -): Effect.Effect< - Prepared, - SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked -> { +): Effect.Effect { return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( Effect.withSpan("SessionContextEpoch.prepare"), ) @@ -145,7 +142,7 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic .pipe(Effect.orDie) }) -const requireEffectiveAgent = Effect.fnUntraced(function* ( +const requireAgentSelection = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, agent: AgentV2.ID, @@ -156,7 +153,7 @@ const requireEffectiveAgent = Effect.fnUntraced(function* ( .where(eq(SessionTable.id, sessionID)) .get() .pipe(Effect.orDie) - if (!selected || AgentV2.effectiveID(selected.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch()) }) export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( @@ -215,7 +212,7 @@ const insert = Effect.fnUntraced(function* ( .get() .pipe(Effect.orDie) if (!placed) return yield* Effect.die(new LocationMismatch()) - if (AgentV2.effectiveID(placed.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch()) const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) yield* db .insert(SessionContextEpochTable) @@ -253,7 +250,7 @@ const replace = Effect.fnUntraced(function* ( .transaction( () => Effect.gen(function* () { - yield* requireEffectiveAgent(db, sessionID, agent) + yield* requireAgentSelection(db, sessionID, agent) const updated = yield* db .update(SessionContextEpochTable) .set({ @@ -287,13 +284,14 @@ const fence = Effect.fnUntraced(function* ( expectedRevision: number, ) { const current = yield* db - .select({ agent: SessionTable.agent, revision: SessionContextEpochTable.revision }) + .select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision }) .from(SessionContextEpochTable) .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) .where(eq(SessionContextEpochTable.session_id, sessionID)) .get() .pipe(Effect.orDie) - if (!current || AgentV2.effectiveID(current.agent) !== agent) return yield* Effect.die(new AgentMismatch()) + if (!current || (current.selected !== null && current.selected !== agent)) + return yield* Effect.die(new AgentMismatch()) if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) }) @@ -304,13 +302,22 @@ export const current = Effect.fn("SessionContextEpoch.current")(function* ( revision: number, ) { const value = yield* db - .select({ agent: SessionTable.agent, revision: SessionContextEpochTable.revision }) + .select({ + agent: SessionContextEpochTable.agent, + selected: SessionTable.agent, + revision: SessionContextEpochTable.revision, + }) .from(SessionContextEpochTable) .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) .where(eq(SessionContextEpochTable.session_id, sessionID)) .get() .pipe(Effect.orDie) - return value !== undefined && AgentV2.effectiveID(value.agent) === agent && value.revision === revision + return ( + value !== undefined && + value.agent === agent && + (value.selected === null || value.selected === agent) && + value.revision === revision + ) }) const advance = Effect.fnUntraced(function* ( diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a9a033331ee8..4842c483bc41 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,5 +1,5 @@ import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, FiberSet, Layer, Schema, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Database } from "../../database/database" import { EventV2 } from "../../event" @@ -133,14 +133,8 @@ export const layer = Layer.effect( defect instanceof SessionContextEpoch.AgentMismatch ? Effect.die(new RetryTurn(promotion)) : Effect.die(defect), ) - const sameModel = (left: ModelV2.Ref | undefined, right: ModelV2.Ref | undefined) => - left === right || - (left !== undefined && - right !== undefined && - left.id === right.id && - left.providerID === right.providerID && - left.variant === right.variant) - const loadSystemContext = (agent: AgentV2.ID) => + const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref)) + const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe( Effect.map(SystemContext.combine), ) @@ -150,14 +144,13 @@ export const layer = Layer.effect( promotion: SessionInput.Delivery | undefined, ) { const session = yield* getSession(sessionID) - const agent = yield* agents.resolve(session.agent) - const agentID = agent?.id ?? AgentV2.defaultID + const agent = yield* agents.select(session.agent) const initialized = yield* SessionContextEpoch.initialize( db, - loadSystemContext(agentID), + loadSystemContext(agent), session.id, session.location, - agentID, + agent.id, ).pipe(retryAgentMismatch(promotion)) const toolFibers = yield* FiberSet.make() let needsContinuation = false @@ -174,19 +167,19 @@ export const layer = Layer.effect( (yield* SessionContextEpoch.prepare( db, events, - loadSystemContext(agentID), + loadSystemContext(agent), session.id, session.location, - agentID, + agent.id, ).pipe(retryAgentMismatch(undefined))) const current = yield* getSession(sessionID) - if ((yield* agents.resolve(current.agent))?.id !== agent?.id || !sameModel(current.model, session.model)) + if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model)) return yield* Effect.die(new RetryTurn(undefined)) const model = yield* models.resolve(session) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, - system: [agent?.system, system.baseline] + system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: toLLMMessages(context, model), @@ -194,7 +187,7 @@ export const layer = Layer.effect( }) const publisher = createLLMEventPublisher(events, { sessionID: session.id, - agent: agentID, + agent: agent.id, model: { id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), @@ -203,7 +196,7 @@ export const layer = Layer.effect( }) const withPublication = Semaphore.makeUnsafe(1).withPermit const publish = (event: LLMEvent) => withPublication(publisher.publish(event)) - if (!(yield* SessionContextEpoch.current(db, session.id, agentID, system.revision))) + if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) return yield* Effect.die(new RetryTurn(undefined)) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => @@ -211,7 +204,7 @@ export const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return needsContinuation = true - yield* tools.settle({ sessionID: session.id, agent: agentID, call: event }).pipe( + yield* tools.settle({ sessionID: session.id, agent: agent.id, call: event }).pipe( Effect.catchCause((cause) => { if (isQuestionRejected(cause)) return Effect.failCause(cause) return Effect.succeed({ diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index e9fbadfffac6..92fb4c0a6290 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -32,7 +32,7 @@ const render = (skills: ReadonlyArray) => ].join("\n") export interface Interface { - readonly load: (agentID: AgentV2.ID) => Effect.Effect + readonly load: (agent: AgentV2.Selection) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SkillGuidance") {} @@ -40,14 +40,13 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const agents = yield* AgentV2.Service const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service return Service.of({ - load: Effect.fn("SkillGuidance.load")(function* (agentID) { + load: Effect.fn("SkillGuidance.load")(function* (selection) { yield* boot.wait() - const agent = yield* agents.get(agentID) + const agent = selection.info if (!agent) return SystemContext.empty const permitted = SkillV2.available(yield* skills.list(), agent) if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny") diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 42da4a95ddc1..b26e41df0f56 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -47,7 +47,6 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AgentV2 } from "@opencode-ai/core/agent" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -189,13 +188,13 @@ const systemContext = Layer.effectDiscard( ), ).pipe(Layer.provideMerge(SystemContextRegistry.layer)) const skillGuidance = Layer.mock(SkillGuidance.Service, { - load: (agentID) => + load: (agent) => Effect.succeed( - skillBaselines.has(agentID) + skillBaselines.has(agent.id) ? SystemContext.make({ key: SystemContext.Key.make("test/skill-guidance"), codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed(skillBaselines.get(agentID)!), + load: Effect.succeed(skillBaselines.get(agent.id)!), baseline: String, update: (_previous, current) => current, removed: () => "Skill guidance removed", diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 2da8375e3a1a..fce6ea1087eb 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -28,9 +28,8 @@ const denied = new SkillV2.Info({ content: "Denied guidance", }) -const layer = (agent: AgentV2.Info, list: () => SkillV2.Info[], wait: () => void = () => {}) => +const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) => SkillGuidance.layer.pipe( - Layer.provide(Layer.mock(AgentV2.Service, { get: (id) => Effect.succeed(id === agent.id ? agent : undefined) })), Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), ) @@ -45,7 +44,9 @@ describe("SkillGuidance", () => { let waited = 0 return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service - const initialized = yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize)) + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) expect(waited).toBe(1) expect(initialized.baseline).toBe( @@ -64,7 +65,7 @@ describe("SkillGuidance", () => { skills = [] expect( yield* guidance - .load(build) + .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))), ).toMatchObject({ _tag: "Updated", @@ -73,7 +74,6 @@ describe("SkillGuidance", () => { }).pipe( Effect.provide( layer( - agent, () => skills, () => waited++, ), @@ -88,11 +88,13 @@ describe("SkillGuidance", () => { }) return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service - expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + expect( + yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), + ).toEqual({ baseline: "", snapshot: {}, }) - }).pipe(Effect.provide(layer(agent, () => [effect]))) + }).pipe(Effect.provide(layer(() => [effect]))) }) it.effect("omits guidance when a resource-specific denial follows the global denial", () => { @@ -105,11 +107,13 @@ describe("SkillGuidance", () => { }) return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service - expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + expect( + yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), + ).toEqual({ baseline: "", snapshot: {}, }) - }).pipe(Effect.provide(layer(agent, () => [effect]))) + }).pipe(Effect.provide(layer(() => [effect]))) }) it.effect("retains specifically allowed skills after a global denial", () => { @@ -122,10 +126,10 @@ describe("SkillGuidance", () => { }) return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service - expect((yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).baseline).toContain( - "effect", - ) - }).pipe(Effect.provide(layer(agent, () => [effect]))) + expect( + (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline, + ).toContain("effect") + }).pipe(Effect.provide(layer(() => [effect]))) }) it.effect("omits guidance when a specifically allowed skill is denied again", () => { @@ -139,10 +143,12 @@ describe("SkillGuidance", () => { }) return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service - expect(yield* guidance.load(build).pipe(Effect.flatMap(SystemContext.initialize))).toEqual({ + expect( + yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), + ).toEqual({ baseline: "", snapshot: {}, }) - }).pipe(Effect.provide(layer(agent, () => [effect]))) + }).pipe(Effect.provide(layer(() => [effect]))) }) })