feat(core): admit v2 skill guidance (#30843)

This commit is contained in:
Kit Langton 2026-06-05 11:19:55 -04:00 committed by GitHub
commit 3f64b5e621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3119 additions and 174 deletions

View file

@ -10,6 +10,7 @@ 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 Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
@ -42,6 +43,11 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
}
}
export interface Selection {
readonly id: ID
readonly info: Info | undefined
}
type Data = {
agents: Map<ID, Info>
default?: ID
@ -61,6 +67,7 @@ export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly all: () => Effect.Effect<Info[]>
}
@ -120,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())
}),

View file

@ -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[]

View file

@ -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

View file

@ -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<File>("InstructionContext.File")({
path: AbsolutePath,

View file

@ -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"
@ -40,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<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
@ -68,6 +69,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
).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<LocationServiceMap>()("
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,

View file

@ -33,14 +33,18 @@ 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,
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),
}
export const Request = Schema.Struct({
id: ID,
...RequestFields,
}).annotate({ identifier: "PermissionV2.Request" })
export type Request = typeof Request.Type
@ -49,12 +53,8 @@ export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
sessionID: SessionV2.ID,
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,
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<Service, Interface>()("@opencode/v2
interface Pending {
readonly request: Request
readonly agent?: AgentV2.ID
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
@ -159,10 +160,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 = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: AssertInput, rules: Ruleset) {
@ -174,7 +179,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)
@ -194,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<void, RejectedError | CorrectedError>()
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
@ -211,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 }
})
@ -225,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(() => {
@ -281,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).pipe(
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue

View file

@ -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"

View file

@ -2,11 +2,11 @@ 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"
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"
@ -18,6 +18,11 @@ type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
export class AgentMismatch extends Error {}
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
"SessionContextEpoch.AgentReplacementBlocked",
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
) {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
@ -31,15 +36,17 @@ const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect
interface Prepared {
readonly baseline: string
readonly baselineSeq: number
readonly revision: number
}
export function initialize(
db: DatabaseService,
context: SystemContextRegistry.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe(
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
}
@ -47,11 +54,12 @@ export function initialize(
export function prepare(
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
location: Location.Ref,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
agent: AgentV2.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
}
@ -59,30 +67,38 @@ export function prepare(
const prepareOnce = Effect.fnUntraced(function* (
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
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)
return { baseline: generation.baseline, baselineSeq }
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq, revision: 0 }
}
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 === "Unchanged" || result._tag === "ReplacementBlocked")
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
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") {
yield* fence(db, sessionID, agent, stored.revision)
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, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
}
yield* events.publish(
@ -90,19 +106,20 @@ 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* (
db: DatabaseService,
context: SystemContextRegistry.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
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)
return { baseline: generation.baseline, baselineSeq }
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq, revision: 0 }
})
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@ -125,6 +142,20 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
.pipe(Effect.orDie)
})
const requireAgentSelection = 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 || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@ -159,6 +190,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 +198,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,12 +212,14 @@ const insert = Effect.fnUntraced(function* (
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
agent,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
@ -207,26 +241,83 @@ 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* () {
yield* requireAgentSelection(db, sessionID, agent)
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
agent,
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 })
.pipe(Effect.orDie)
})
const fence = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
) {
const current = yield* db
.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 (!updated) return yield* Effect.die(new RevisionMismatch())
if (!current || (current.selected !== null && current.selected !== agent))
return yield* Effect.die(new AgentMismatch())
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: 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 &&
value.agent === agent &&
(value.selected === null || value.selected === agent) &&
value.revision === revision
)
})
const advance = Effect.fnUntraced(function* (

View file

@ -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"

View file

@ -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.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.ModelSwitched, (event) =>
Effect.gen(function* () {
yield* db

View file

@ -5,7 +5,8 @@ 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"
import type { SessionContextEpoch } from "../context-epoch"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"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 {

View file

@ -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 { Cause, DateTime, Effect, FiberSet, Layer, Schema, 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 { 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"
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<unknown>) =>
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: 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 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),
)
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: "steer" | "queue" | undefined,
promotion: SessionInput.Delivery | 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 agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(
db,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
if (promotion) {
@ -148,11 +163,23 @@ export const layer = Layer.effect(
}
}
const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location))
initialized ??
(yield* SessionContextEpoch.prepare(
db,
events,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
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),
@ -160,7 +187,7 @@ export const layer = Layer.effect(
})
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent?.id ?? "build",
agent: agent.id,
model: {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
@ -169,13 +196,15 @@ 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, agent.id, system.revision)))
return yield* Effect.die(new RetryTurn(undefined))
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
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: agent.id, call: event }).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause)) return Effect.failCause(cause)
return Effect.succeed({
@ -245,6 +274,17 @@ export const layer = Layer.effect(
}),
)
}, Effect.scoped)
const runTurn: (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
) => Effect.Effect<boolean, RunError> = (sessionID, promotion) =>
runTurnAttempt(sessionID, promotion).pipe(
Effect.catchDefect((defect) =>
defect instanceof RetryTurn
? Effect.yieldNow.pipe(Effect.andThen(runTurn(sessionID, defect.promotion)))
: Effect.die(defect),
),
)
const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID
@ -254,7 +294,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

View file

@ -11,7 +11,8 @@ 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"
import { AgentV2 } from "../agent"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -169,6 +170,7 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(),
replacement_seq: integer(),

View file

@ -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

View file

@ -54,6 +54,9 @@ export class Info extends Schema.Class<Info>("SkillV2.Info")({
content: Schema.String,
}) {}
export const available = (skills: ReadonlyArray<Info>, 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),
@ -74,7 +77,6 @@ export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
readonly forAgent: (agent: AgentV2.ID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Skill") {}
@ -82,7 +84,6 @@ export class Service extends Context.Service<Service, Interface>()("@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
@ -153,18 +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 (yield* list()).filter(
(skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny",
)
}),
})
}),
)
export const locationLayer = layer.pipe(
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
)
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))

View file

@ -0,0 +1,76 @@
export * as SkillGuidance from "./guidance"
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/index"
const Summary = Schema.Struct({
name: Schema.String,
description: Schema.String,
})
type Summary = typeof Summary.Type
const render = (skills: ReadonlyArray<Summary>) =>
[
"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."]
: [
"<available_skills>",
...skills.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
]),
"</available_skills>",
]),
].join("\n")
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillGuidance") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service
return Service.of({
load: Effect.fn("SkillGuidance.load")(function* (selection) {
yield* boot.wait()
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")
return SystemContext.empty
const available = permitted
.flatMap((skill) =>
skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }],
)
.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

View file

@ -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* () {

View file

@ -1,4 +1,4 @@
export * as SystemContext from "./system-context"
export * as SystemContext from "./index"
import { Effect, Option, Schema } from "effect"

View file

@ -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

View file

@ -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<PermissionV2.AssertInput, "sessionID" | "source">,
input: Omit<PermissionV2.AssertInput, "sessionID" | "agent" | "source">,
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
}
@ -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* (

View file

@ -25,18 +25,13 @@ export const Success = Schema.Struct({
resource: ToolOutputStore.Resource.pipe(Schema.optional),
})
export const description = (skills: ReadonlyArray<SkillV2.Info>) =>
[
"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<string>) => {
const directory = path.dirname(skill.location)
@ -57,10 +52,8 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>)
].join("\n")
}
const notFound = (name: string, skills: ReadonlyArray<SkillV2.Info>) =>
new ToolFailure({
message: `Skill "${name}" not found. Available skills: ${skills.map((skill) => skill.name).join(", ") || "none"}`,
})
const unableToLoad = (name: string, error?: unknown) =>
new ToolFailure({ message: `Unable to load skill ${name}`, error })
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -70,9 +63,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 })],
@ -85,14 +77,17 @@ 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* unableToLoad(parameters.name)
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,
@ -105,13 +100,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)))))
}),
}),
)