feat(core): admit v2 skill guidance (#30843)
This commit is contained in:
parent
cc487dd032
commit
3f64b5e621
40 changed files with 3119 additions and 174 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL;
|
||||
2083
packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json
generated
Normal file
2083
packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}),
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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* (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
76
packages/core/src/skill/guidance.ts
Normal file
76
packages/core/src/skill/guidance.ts
Normal 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
|
||||
|
|
@ -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* () {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export * as SystemContext from "./system-context"
|
||||
export * as SystemContext from "./index"
|
||||
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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* (
|
||||
|
|
|
|||
|
|
@ -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)))))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
transform,
|
||||
sources: () => Effect.succeed(sources),
|
||||
list: () => Effect.succeed([]),
|
||||
forAgent: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -67,6 +68,11 @@ 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, 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(
|
||||
|
|
@ -86,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* () {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"))).not.toHaveProperty("agent")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows and denies from explicit rules without asking", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
|
|
|
|||
|
|
@ -20,7 +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 { 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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -42,7 +43,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 { 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 { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
|
|
@ -146,14 +148,16 @@ 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"
|
||||
let systemRemoved = false
|
||||
let systemUnavailable = false
|
||||
let systemLoadHook = Effect.void
|
||||
const skillBaselines = new Map<AgentV2.ID, string>()
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
|
|
@ -183,6 +187,21 @@ const systemContext = Layer.effectDiscard(
|
|||
),
|
||||
),
|
||||
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
skillBaselines.has(agent.id)
|
||||
? SystemContext.make({
|
||||
key: SystemContext.Key.make("test/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(skillBaselines.get(agent.id)!),
|
||||
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 +211,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 +242,7 @@ const it = testEffect(
|
|||
echo,
|
||||
models,
|
||||
systemContext,
|
||||
skillGuidance,
|
||||
runner,
|
||||
coordinator,
|
||||
execution,
|
||||
|
|
@ -256,6 +277,8 @@ const setup = Effect.gen(function* () {
|
|||
systemRemoved = false
|
||||
systemUnavailable = false
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
responseStream = undefined
|
||||
|
|
@ -805,6 +828,304 @@ 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("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("retries an agent switch before the final provider-dispatch boundary", () =>
|
||||
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)
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["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("retries a model switch before the final provider-dispatch boundary", () =>
|
||||
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)
|
||||
expect(requests.map((request) => request.model)).toEqual([replacementModel])
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial 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
|
||||
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("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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
154
packages/core/test/skill/guidance.test.ts
Normal file
154
packages/core/test/skill/guidance.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import path from "path"
|
||||
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 { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
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 = (list: () => SkillV2.Info[], wait: () => void = () => {}) =>
|
||||
SkillGuidance.layer.pipe(
|
||||
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({ id: agent.id, info: agent })
|
||||
.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.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>effect</name>",
|
||||
" <description>Build applications with Effect</description>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
skills = []
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining("No skills are currently available."),
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => 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({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [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({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [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({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline,
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [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({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
})
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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),
|
||||
|
|
@ -38,7 +38,9 @@ describe("SkillTool", () => {
|
|||
location: AbsolutePath.make(location),
|
||||
content: "# Effect\n\nGuidance",
|
||||
}
|
||||
let current = [info]
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let deny = false
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
|
@ -55,7 +57,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"),
|
||||
|
|
@ -68,8 +73,7 @@ describe("SkillTool", () => {
|
|||
SkillV2.Service.of({
|
||||
transform: () => Effect.die("unused"),
|
||||
sources: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed([info]),
|
||||
forAgent: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed(current),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
|
|
@ -97,7 +101,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({
|
||||
|
|
@ -141,7 +145,35 @@ 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: "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" })
|
||||
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))
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue