mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:48:28 +00:00
refactor(core): simplify system context into a belief model (#34917)
This commit is contained in:
parent
7ec7413fdb
commit
4045041554
40 changed files with 914 additions and 805 deletions
|
|
@ -159,4 +159,5 @@ const table = sqliteTable("session", {
|
|||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry.
|
||||
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as InstructionContext from "./instruction-context"
|
||||
|
||||
import { Array, Effect, Layer, Schema } from "effect"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
|
|
@ -8,7 +8,6 @@ import { Global } from "./global"
|
|||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
|
|
@ -19,12 +18,18 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
|||
const Files = Schema.Array(File)
|
||||
const key = SystemContext.Key.make("core/instructions")
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionContext") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||
SystemContext.make({
|
||||
|
|
@ -71,28 +76,24 @@ const layer = Layer.effectDiscard(
|
|||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.register({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
return Service.of({
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "instruction-context",
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] })
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ import { SessionTodo } from "./session/todo"
|
|||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { InstructionContext } from "./instruction-context"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { SessionInstructions } from "./session/instructions"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
|
|
@ -68,8 +68,8 @@ export const locationServices = LayerNode.group([
|
|||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
MCP.node,
|
||||
|
|
|
|||
|
|
@ -14,16 +14,40 @@ const Summary = Schema.Struct({
|
|||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
...servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(server) => server.server,
|
||||
(before, after) => before.instructions !== after.instructions,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
|
|
@ -50,7 +74,8 @@ export const layer = Layer.effect(
|
|||
return (
|
||||
owned.length === 0 ||
|
||||
owned.some(
|
||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
})
|
||||
|
|
@ -61,11 +86,7 @@ export const layer = Layer.effect(
|
|||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -11,20 +11,48 @@ const Summary = Schema.Struct({
|
|||
description: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const entries = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
])
|
||||
|
||||
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
[
|
||||
"Project references provide additional directories that can be accessed when relevant.",
|
||||
"<available_references>",
|
||||
...references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
]),
|
||||
...entries(references),
|
||||
"</available_references>",
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(reference) => reference.name,
|
||||
(before, after) => before.path !== after.path || before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
|
@ -52,11 +80,7 @@ const layer = Layer.effect(
|
|||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
|||
},
|
||||
) {}
|
||||
|
||||
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||
export { MessageDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -466,7 +466,9 @@ const layer = Layer.effect(
|
|||
text: skill.content,
|
||||
})
|
||||
if (input.resume !== false)
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
|
|
@ -550,7 +552,9 @@ const layer = Layer.effect(
|
|||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
|
|
|
|||
131
packages/core/src/session/context-checkpoint.ts
Normal file
131
packages/core/src/session/context-checkpoint.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
export * as SessionContextCheckpoint from "./context-checkpoint"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextCheckpointTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
|
||||
|
||||
/**
|
||||
* Loads or creates the session's durable context checkpoint, narrating any
|
||||
* drift since the model was last told as a chronological update. Completed
|
||||
* compaction rebaselines; nothing else rewrites the baseline. Runs before
|
||||
* input promotion so a blocked first turn leaves pending inputs untouched.
|
||||
*/
|
||||
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const baseline = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, baseline)
|
||||
return { baseline: baseline.text, baselineSeq }
|
||||
}
|
||||
|
||||
// The applied record is comparison state only; an undecodable one heals by
|
||||
// treating every source as new, re-announcing baselines as updates.
|
||||
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
|
||||
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
|
||||
const baseline = yield* SystemContext.rebaseline(value, applied)
|
||||
yield* rewrite(db, sessionID, compaction.seq, baseline)
|
||||
return { baseline: baseline.text, baselineSeq: compaction.seq }
|
||||
}
|
||||
const result = yield* SystemContext.reconcile(value, applied)
|
||||
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const rewrite = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
applied: SystemContext.Applied,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: applied })
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
|
@ -10,15 +10,3 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
|||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
|
||||
"Session.ContextSnapshotDecodeError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
details: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Database } from "../database/database"
|
|||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
import { SessionContextCheckpointTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
|
|
@ -33,6 +33,9 @@ const messageRows = Effect.fnUntraced(function* (
|
|||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
// Keep system updates visible in the gap between a completed compaction
|
||||
// and the next prepared turn's rebaseline, when their content is not yet
|
||||
// folded into a new baseline.
|
||||
compaction
|
||||
? or(
|
||||
gte(SessionMessageTable.seq, compaction.seq),
|
||||
|
|
@ -67,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
|||
const [epoch, compaction] = yield* Effect.all(
|
||||
[
|
||||
db
|
||||
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.select({ baselineSeq: SessionContextCheckpointTable.baseline_seq })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
latestCompaction(db, sessionID),
|
||||
|
|
@ -79,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
|||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ import { SessionMessage } from "./message"
|
|||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { SessionContextCheckpoint } from "./context-checkpoint"
|
||||
import {
|
||||
MessageTable,
|
||||
PartTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
|
||||
|
|
@ -156,12 +163,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)),
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
if (event.data.messageID && !boundary)
|
||||
return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
|
|
@ -206,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
|
||||
// The fork inherits the parent's transcript, so it inherits the context
|
||||
// checkpoint that transcript was built against: copied message seqs keep
|
||||
// folding at the same baseline horizon.
|
||||
const checkpoint = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, event.data.parentID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (checkpoint) {
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ ...checkpoint, session_id: event.data.sessionID })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const usage = emptyUsage()
|
||||
let cursor = -1
|
||||
while (true) {
|
||||
|
|
@ -452,7 +480,7 @@ const layer = Layer.effectDiscard(
|
|||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
|
|
@ -666,7 +694,7 @@ const layer = Layer.effectDiscard(
|
|||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
|||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
|
@ -12,7 +12,6 @@ export type RunError =
|
|||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ import { ModelV2 } from "../../model"
|
|||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextRegistry } from "../../system-context/registry"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { InstructionContext } from "../../instruction-context"
|
||||
import { SkillGuidance } from "../../skill/guidance"
|
||||
import { ReferenceGuidance } from "../../reference/guidance"
|
||||
import { McpGuidance } from "../../mcp/guidance"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
import { SessionContextCheckpoint } from "../context-checkpoint"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
|
|
@ -102,7 +103,8 @@ const layer = Layer.effect(
|
|||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const builtins = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const mcpGuidance = yield* McpGuidance.Service
|
||||
|
|
@ -170,9 +172,16 @@ const layer = Layer.effect(
|
|||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||
|
||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
|
||||
concurrency: "unbounded",
|
||||
}).pipe(Effect.map(SystemContext.combine))
|
||||
Effect.all(
|
||||
[
|
||||
builtins.load(),
|
||||
instructions.load(),
|
||||
skillGuidance.load(agent),
|
||||
referenceGuidance.load(),
|
||||
mcpGuidance.load(agent),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(SystemContext.combine))
|
||||
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -184,7 +193,9 @@ const layer = Layer.effect(
|
|||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first turn leaves pending inputs untouched.
|
||||
const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
|
|
@ -198,10 +209,8 @@ const layer = Layer.effect(
|
|||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const system =
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep
|
||||
|
|
@ -211,7 +220,10 @@ const layer = Layer.effect(
|
|||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
||||
system: [
|
||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
|
|
@ -462,7 +474,8 @@ export const node = makeLocationNode({
|
|||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
McpGuidance.node,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
|||
})
|
||||
}
|
||||
|
||||
const providerOptions = (model: ModelV2.Info) => {
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (Object.keys(model.request.settings).length === 0) return undefined
|
||||
if (model.api.type !== "aisdk") return undefined
|
||||
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
|
||||
|
|
|
|||
|
|
@ -165,12 +165,12 @@ export const SessionInputTable = sqliteTable(
|
|||
],
|
||||
)
|
||||
|
||||
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ import { fromRow } from "./info"
|
|||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
|
|
@ -39,9 +35,6 @@ const layer = Layer.effect(
|
|||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionHistory.load(db, sessionID)
|
||||
}),
|
||||
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
|
||||
return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -13,23 +13,47 @@ const Summary = Schema.Struct({
|
|||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||
skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
])
|
||||
|
||||
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>", ...entries(skills), "</available_skills>"]),
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(skill) => skill.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
"<available_skills>",
|
||||
...skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
|
|
@ -61,11 +85,7 @@ const layer = Layer.effect(
|
|||
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"),
|
||||
update,
|
||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
export * as SystemContextBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "./index"
|
||||
import { InstructionContext } from "../instruction-context"
|
||||
import { SystemContextRegistry } from "./registry"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
||||
const builtIns = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextBuiltIns") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const environment = [
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
|
|
@ -39,12 +41,8 @@ const builtIns = Layer.effectDiscard(
|
|||
}),
|
||||
])
|
||||
|
||||
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
return Service.of({ load: () => Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "system-context-builtins",
|
||||
layer: builtIns,
|
||||
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@ import { Effect, Option, Schema } from "effect"
|
|||
*
|
||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
||||
* with contexts built from other value types. Interpreters observe the composed
|
||||
* context once, then produce a durable structured
|
||||
* `Snapshot` alongside the exact model-visible baseline or update text.
|
||||
* with contexts built from other value types.
|
||||
*
|
||||
* The durable `Applied` record tracks what the model was last told, per source:
|
||||
* it is the model's current belief. Interpreters uphold one invariant —
|
||||
* `reconcile` never rewrites the baseline; it only narrates drift as update
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first turn) produce
|
||||
* baseline text.
|
||||
*
|
||||
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||
* removing a source from the context: refresh preserves the admitted snapshot,
|
||||
* and replacement waits rather than silently constructing an incomplete baseline.
|
||||
* removing a source from the context: the model's prior belief stands.
|
||||
* `reconcile` retains the applied value silently, and `rebaseline` restates the
|
||||
* belief by rendering the last-applied value instead of a live observation.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
|
@ -45,39 +50,30 @@ export interface SystemContext {
|
|||
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
||||
}
|
||||
|
||||
/** Durable comparison state for one admitted source. */
|
||||
export const SourceSnapshot = Schema.Struct({
|
||||
/** The value last applied to the model for one admitted source. */
|
||||
export const AppliedSource = Schema.Struct({
|
||||
value: Schema.Json,
|
||||
removed: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
export type SourceSnapshot = typeof SourceSnapshot.Type
|
||||
export type AppliedSource = typeof AppliedSource.Type
|
||||
|
||||
/** Durable structured comparison state for one active context generation. */
|
||||
export const Snapshot = Schema.Record(Key, SourceSnapshot)
|
||||
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
|
||||
/** Durable record of what the model currently believes, per source. */
|
||||
export const Applied = Schema.Record(Key, AppliedSource)
|
||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
||||
|
||||
export interface Generation {
|
||||
readonly baseline: string
|
||||
readonly snapshot: Snapshot
|
||||
/** A rendered baseline together with the applied values it was rendered from. */
|
||||
export interface Baseline {
|
||||
readonly text: string
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface Updated {
|
||||
readonly _tag: "Updated"
|
||||
readonly text: string
|
||||
readonly snapshot: Snapshot
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface ReplacementReady {
|
||||
readonly _tag: "ReplacementReady"
|
||||
readonly generation: Generation
|
||||
}
|
||||
|
||||
export interface ReplacementBlocked {
|
||||
readonly _tag: "ReplacementBlocked"
|
||||
}
|
||||
|
||||
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
|
||||
|
||||
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||
"SystemContext.InitializationBlocked",
|
||||
|
|
@ -98,36 +94,24 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
|||
|
||||
interface PackedSource {
|
||||
readonly key: Key
|
||||
readonly load: Effect.Effect<Loaded | Unavailable>
|
||||
readonly load: Effect.Effect<Observed | Unavailable>
|
||||
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
|
||||
readonly recall: (stored: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
readonly baseline: () => Rendered
|
||||
readonly compare: (previous: Schema.Json) => Compared
|
||||
interface Observed {
|
||||
readonly applied: AppliedSource
|
||||
readonly baseline: () => string
|
||||
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
|
||||
readonly update: (previous: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Rendered {
|
||||
readonly text: string
|
||||
readonly snapshot: SourceSnapshot
|
||||
}
|
||||
|
||||
type Compared =
|
||||
| { readonly _tag: "Incompatible" }
|
||||
| { readonly _tag: "Unchanged" }
|
||||
| { readonly _tag: "Updated"; readonly render: () => Rendered }
|
||||
|
||||
interface AvailableEntry extends Loaded {
|
||||
readonly _tag: "Available"
|
||||
interface Entry {
|
||||
readonly key: Key
|
||||
readonly recall: PackedSource["recall"]
|
||||
readonly observed: Observed | Unavailable
|
||||
}
|
||||
|
||||
interface UnavailableEntry {
|
||||
readonly _tag: "Unavailable"
|
||||
readonly key: Key
|
||||
}
|
||||
|
||||
type Entry = AvailableEntry | UnavailableEntry
|
||||
|
||||
/** The identity context. */
|
||||
export const empty = context([])
|
||||
|
||||
|
|
@ -136,42 +120,65 @@ export function make<A>(source: Source<A>): SystemContext {
|
|||
const decode = Schema.decodeUnknownOption(source.codec)
|
||||
const encode = Schema.encodeSync(source.codec)
|
||||
const equivalent = Schema.toEquivalence(source.codec)
|
||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
||||
return context([
|
||||
{
|
||||
key: source.key,
|
||||
recall: (stored) =>
|
||||
Option.match(decode(stored.value), {
|
||||
onNone: () => undefined,
|
||||
onSome: baseline,
|
||||
}),
|
||||
load: source.load.pipe(
|
||||
Effect.map((value) => {
|
||||
if (isUnavailable(value)) return value
|
||||
const snapshot = (): SourceSnapshot => ({
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
})
|
||||
return {
|
||||
baseline: (): Rendered => ({
|
||||
text: requireText(source.key, "baseline", source.baseline(value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
compare: (previous): Compared =>
|
||||
Option.match(decode(previous), {
|
||||
onNone: (): Compared => ({ _tag: "Incompatible" }),
|
||||
onSome: (decoded): Compared =>
|
||||
applied: {
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
},
|
||||
baseline: () => baseline(value),
|
||||
update: (previous) =>
|
||||
Option.match(decode(previous.value), {
|
||||
onNone: () => baseline(value),
|
||||
onSome: (decoded) =>
|
||||
equivalent(decoded, value)
|
||||
? { _tag: "Unchanged" }
|
||||
: {
|
||||
_tag: "Updated",
|
||||
render: () => ({
|
||||
text: requireText(source.key, "update", source.update(decoded, value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
},
|
||||
? undefined
|
||||
: requireText(source.key, "update", source.update(decoded, value)),
|
||||
}),
|
||||
}
|
||||
} satisfies Observed
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed three-way diff for list-shaped sources rendering delta updates.
|
||||
* `changed` compares two values sharing a key; entries equal under it are dropped.
|
||||
*/
|
||||
export function diffByKey<A>(
|
||||
previous: ReadonlyArray<A>,
|
||||
current: ReadonlyArray<A>,
|
||||
key: (value: A) => string,
|
||||
changed: (previous: A, current: A) => boolean,
|
||||
): {
|
||||
readonly added: ReadonlyArray<A>
|
||||
readonly removed: ReadonlyArray<A>
|
||||
readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }>
|
||||
} {
|
||||
const currentKeys = new Set(current.map(key))
|
||||
const previousByKey = new Map(previous.map((value) => [key(value), value] as const))
|
||||
return {
|
||||
added: current.filter((value) => !previousByKey.has(key(value))),
|
||||
removed: previous.filter((value) => !currentKeys.has(key(value))),
|
||||
changed: current.flatMap((value) => {
|
||||
const before = previousByKey.get(key(value))
|
||||
return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
||||
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
||||
const sources = values.flatMap((value) => value[ContextTypeId])
|
||||
|
|
@ -183,111 +190,91 @@ const observe = (value: SystemContext) =>
|
|||
Effect.forEach(
|
||||
value[ContextTypeId],
|
||||
(source) =>
|
||||
source.load.pipe(
|
||||
Effect.map(
|
||||
(result): Entry =>
|
||||
result === unavailable
|
||||
? { _tag: "Unavailable", key: source.key }
|
||||
: { _tag: "Available", key: source.key, ...result },
|
||||
),
|
||||
),
|
||||
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Baseline, InitializationBlocked> {
|
||||
return observe(value).pipe(
|
||||
Effect.flatMap((entries) => {
|
||||
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||
return Effect.succeed(initializeObservation(entries))
|
||||
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
|
||||
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed === unavailable) continue
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
return Effect.succeed({ text: render(parts), applied })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
|
||||
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
|
||||
return {
|
||||
baseline: render(rendered.map(([, result]) => result.text)),
|
||||
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciles current source values with one active generation. */
|
||||
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
|
||||
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
|
||||
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): ReconcileResult => {
|
||||
const result = reconcileObservation(entries, previous)
|
||||
if (result._tag === "Unchanged" || result._tag === "Updated") return result
|
||||
return replaceObservation(entries, previous)
|
||||
const updates: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
const stored = get(previous, entry.key)
|
||||
if (entry.observed === unavailable) {
|
||||
// The prior belief stands while the source cannot be observed.
|
||||
if (stored) applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
updates.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const text = entry.observed.update(stored)
|
||||
if (text === undefined) {
|
||||
applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
updates.push(text)
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
const keys = new Set<string>(entries.map((entry) => entry.key))
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(key)) continue
|
||||
const removed = previous[key].removed
|
||||
// An unannounced removal retains the belief; it clears at the next rebaseline.
|
||||
if (removed === undefined) applied[key] = previous[key]
|
||||
else updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function reconcileObservation(
|
||||
entries: ReadonlyArray<Entry>,
|
||||
previous: Snapshot,
|
||||
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
|
||||
const keys = new Set(entries.map((entry) => entry.key))
|
||||
const comparisons = new Map<Key, Compared>()
|
||||
for (const entry of entries) {
|
||||
if (entry._tag === "Unavailable") continue
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const compared = entry.compare(stored.value)
|
||||
if (compared._tag === "Incompatible") return { _tag: "Replace" }
|
||||
comparisons.set(entry.key, compared)
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
if (previous[key].removed === undefined) return { _tag: "Replace" }
|
||||
}
|
||||
|
||||
const snapshot: Record<string, SourceSnapshot> = {}
|
||||
const updates: string[] = []
|
||||
for (const entry of entries) {
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (entry._tag === "Unavailable") {
|
||||
if (stored) snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
const rendered = entry.baseline()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
continue
|
||||
}
|
||||
const compared = comparisons.get(entry.key)
|
||||
if (!compared || compared._tag === "Incompatible")
|
||||
throw new Error(`Missing comparison for system context source ${entry.key}`)
|
||||
if (compared._tag === "Unchanged") {
|
||||
snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
const rendered = compared.render()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
const removed = previous[key].removed
|
||||
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
|
||||
updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), snapshot }
|
||||
}
|
||||
|
||||
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
|
||||
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
|
||||
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
|
||||
}
|
||||
|
||||
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||
return { _tag: "ReplacementBlocked" }
|
||||
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
|
||||
export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect<Baseline> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): Baseline => {
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed !== unavailable) {
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const stored = get(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const text = entry.recall(stored)
|
||||
// An undecodable belief cannot be restated; the source re-announces when observable again.
|
||||
if (text === undefined) continue
|
||||
parts.push(text)
|
||||
applied[entry.key] = stored
|
||||
}
|
||||
return { text: render(parts), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||
|
|
@ -298,8 +285,8 @@ function render(parts: ReadonlyArray<string>) {
|
|||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
function getSnapshot(snapshot: Snapshot, key: Key) {
|
||||
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
|
||||
function get(applied: Applied, key: Key) {
|
||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
||||
}
|
||||
|
||||
function isUnavailable(value: unknown): value is Unavailable {
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
export * as SystemContextRegistry from "./registry"
|
||||
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
}).pipe(
|
||||
Effect.flatMap((added) =>
|
||||
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
|
||||
),
|
||||
Effect.as(entry),
|
||||
),
|
||||
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
|
||||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -173,6 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.request).toEqual({
|
||||
settings: {},
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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 { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -22,7 +21,7 @@ const instructionLayer = (input: {
|
|||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
}) =>
|
||||
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
|
||||
AppNodeBuilder.build(InstructionContext.node, [
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
|
|
@ -52,7 +51,7 @@ describe("InstructionContext", () => {
|
|||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const load = SystemContextRegistry.Service.pipe(
|
||||
const load = InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -71,23 +70,23 @@ describe("InstructionContext", () => {
|
|||
)
|
||||
|
||||
const initialized = yield* SystemContext.initialize(yield* load)
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${packageFile}\npackage`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("outside")
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
|
||||
expect(partial).toEqual({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
|
|
@ -95,14 +94,14 @@ describe("InstructionContext", () => {
|
|||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
snapshot: expect.any(Object),
|
||||
applied: expect.any(Object),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Previously loaded instructions no longer apply.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -118,7 +117,7 @@ describe("InstructionContext", () => {
|
|||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -131,7 +130,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
)
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
|
||||
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -147,7 +146,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -187,7 +186,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -231,7 +230,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -261,7 +260,7 @@ describe("InstructionContext", () => {
|
|||
let scanned = false
|
||||
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -293,7 +292,7 @@ describe("InstructionContext", () => {
|
|||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
|
|||
|
|
@ -279,7 +279,11 @@ function agentInfo(value: AgentV2.Info) {
|
|||
return {
|
||||
...value,
|
||||
model: value.model && { ...value.model },
|
||||
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
permissions: value.permissions.map((permission) => ({ ...permission })),
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +292,11 @@ function providerInfo(value: ProviderV2.MutableInfo) {
|
|||
return {
|
||||
...value,
|
||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||
request: { settings: { ...value.request.settings }, headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => {
|
|||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.api = bedrock.api
|
||||
item.request = bedrock.request
|
||||
item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => {
|
|||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
draft.request = { settings: {}, headers: { Existing: "1" }, body: {} }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ describe("AzurePlugin", () => {
|
|||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
|
|
@ -110,7 +110,7 @@ describe("AzurePlugin", () => {
|
|||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
|
@ -131,7 +131,7 @@ describe("AzurePlugin", () => {
|
|||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: " " } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("KiloPlugin", () => {
|
|||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => {
|
|||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => {
|
|||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
|
@ -80,6 +80,7 @@ describe("NvidiaPlugin", () => {
|
|||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = {
|
||||
settings: {},
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ describe("OpencodePlugin", () => {
|
|||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.request = provider.request
|
||||
draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } }
|
||||
})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => {
|
|||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
expect(generation.baseline).toContain("<available_references>")
|
||||
expect(generation.baseline).toContain("<name>docs</name>")
|
||||
expect(generation.baseline).toContain("<path>/docs</path>")
|
||||
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
|
||||
expect(generation.text).toContain("<available_references>")
|
||||
expect(generation.text).toContain("<name>docs</name>")
|
||||
expect(generation.text).toContain("<path>/docs</path>")
|
||||
expect(generation.text).toContain("<description>Use for product documentation</description>")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
|
|
@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => {
|
|||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||
)
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => {
|
|||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
|
|
@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => {
|
|||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("announces added and removed references as deltas", () => {
|
||||
const reference = (name: string, description: string) =>
|
||||
new Reference.Info({
|
||||
name,
|
||||
path: AbsolutePath.make(`/${name}`),
|
||||
description,
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }),
|
||||
})
|
||||
let references = [reference("docs", "Use for product documentation")]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
||||
const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied)
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New project references are available in addition to those previously listed:",
|
||||
" <reference>",
|
||||
" <name>examples</name>",
|
||||
" <path>/examples</path>",
|
||||
" <description>Use for examples</description>",
|
||||
" </reference>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
references = [reference("examples", "Use for examples")]
|
||||
expect(
|
||||
yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following project references are no longer available and must not be used: docs.",
|
||||
})
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
|||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import {
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
|
|
@ -67,6 +72,10 @@ describe("SessionProjector", () => {
|
|||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
||||
.run()
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
|
|
@ -93,6 +102,8 @@ describe("SessionProjector", () => {
|
|||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([boundary])
|
||||
// A committed revert resets the context checkpoint so the next turn re-initializes.
|
||||
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
|
|
@ -72,7 +73,8 @@ const model = OpenAIChat.route
|
|||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
|
|
@ -81,7 +83,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
@ -116,7 +119,8 @@ const it = testEffect(
|
|||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
|
|
@ -129,7 +133,8 @@ const it = testEffect(
|
|||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { QuestionV2 } from "@opencode-ai/core/question"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
|
|
@ -46,14 +45,15 @@ import { Config } from "@opencode-ai/core/config"
|
|||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import {
|
||||
SessionContextEpochTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} 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 { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
|
|
@ -169,35 +169,28 @@ 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) =>
|
||||
registry.register({
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
}),
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
|
|
@ -236,7 +229,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
@ -274,7 +268,8 @@ const it = testEffect(
|
|||
ToolRegistry.toolsNode,
|
||||
echoNode,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
|
|
@ -287,7 +282,8 @@ const it = testEffect(
|
|||
[LayerNodePlatform.llmClient, client],
|
||||
[PermissionV2.node, permission],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
@ -699,8 +695,8 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
|
|
@ -731,8 +727,8 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
|
|
@ -745,7 +741,36 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
|
||||
it.effect("copies the context checkpoint to a fork", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const forked = yield* session.fork({ sessionID })
|
||||
|
||||
const parent = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(parent).toBeDefined()
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ ...parent!, session_id: forked.id })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("heals an undecodable stored applied record by re-announcing context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -754,19 +779,28 @@ describe("SessionRunnerLLM", () => {
|
|||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: { invalid: { value: "bad" } } })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
|
||||
expect(requests).toHaveLength(0)
|
||||
// Comparison state was lost, so every source re-announces as new.
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }])
|
||||
const healed = yield* db
|
||||
.select({ snapshot: SessionContextCheckpointTable.snapshot })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -787,8 +821,8 @@ describe("SessionRunnerLLM", () => {
|
|||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
|
|
@ -1049,8 +1083,8 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
|
|
@ -1085,15 +1119,15 @@ describe("SessionRunnerLLM", () => {
|
|||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
"model-switched",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
|
|
@ -1361,7 +1395,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
|
||||
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1393,8 +1427,9 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
||||
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe("SkillGuidance", () => {
|
|||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
|
|
@ -65,16 +65,82 @@ describe("SkillGuidance", () => {
|
|||
"</available_skills>",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("manual")
|
||||
expect(initialized.text).not.toContain("manual")
|
||||
|
||||
skills = []
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))),
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining("No skills are currently available."),
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
const debugging = SkillV2.Info.make({
|
||||
name: "debugging",
|
||||
description: "Diagnose hard bugs",
|
||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
content: "Debugging guidance",
|
||||
})
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied)))
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New skills are available in addition to those previously listed:",
|
||||
" <skill>",
|
||||
" <name>debugging</name>",
|
||||
" <description>Diagnose hard bugs</description>",
|
||||
" </skill>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
skills = [debugging]
|
||||
const removed = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(
|
||||
Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})),
|
||||
)
|
||||
expect(removed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("restates the full skill list when a description changes", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
),
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
|
@ -89,8 +155,8 @@ describe("SkillGuidance", () => {
|
|||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
|
@ -108,8 +174,8 @@ describe("SkillGuidance", () => {
|
|||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
|
@ -125,7 +191,7 @@ describe("SkillGuidance", () => {
|
|||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline,
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text,
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
|
@ -144,8 +210,8 @@ describe("SkillGuidance", () => {
|
|||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ 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 { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ const locationLayer = Layer.succeed(
|
|||
),
|
||||
),
|
||||
)
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node])
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
[Location.node, locationLayer],
|
||||
|
|
@ -58,10 +58,10 @@ describe("SystemContextBuiltIns", () => {
|
|||
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
|
|
@ -80,11 +80,11 @@ describe("SystemContextBuiltIns", () => {
|
|||
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied)
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
|
|
@ -96,20 +96,24 @@ describe("SystemContextBuiltIns", () => {
|
|||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const builtIns = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const context = {
|
||||
load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)),
|
||||
}
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe(
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ describe("SystemContext", () => {
|
|||
removed: () => "Date removed",
|
||||
})
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
|
||||
it.effect("loads once and initializes a baseline with the applied values", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.combine([
|
||||
|
|
@ -55,8 +55,8 @@ describe("SystemContext", () => {
|
|||
])
|
||||
|
||||
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
snapshot: {
|
||||
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
|
|
@ -84,7 +84,7 @@ describe("SystemContext", () => {
|
|||
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||
snapshot: {
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
},
|
||||
|
|
@ -113,19 +113,17 @@ describe("SystemContext", () => {
|
|||
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Available skill: effect",
|
||||
snapshot: { "core/skills": { value: "effect" } },
|
||||
applied: { "core/skills": { value: "effect" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains admitted snapshots while a source is temporarily unavailable", () =>
|
||||
it.effect("retains the belief while a source is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -152,17 +150,29 @@ describe("SystemContext", () => {
|
|||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Instructions removed; stop applying them.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||
it.effect("retains an unannounced removal silently", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
||||
_tag: "Unchanged",
|
||||
})
|
||||
|
||||
// The retained belief survives alongside other updates.
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||
).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "effect",
|
||||
applied: {
|
||||
"core/skills": { value: "effect" },
|
||||
"core/date": { value: "2026-06-04" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -189,17 +199,48 @@ describe("SystemContext", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a stored value no longer decodes", () =>
|
||||
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces from one coherent source observation", () =>
|
||||
it.effect("renders undecodable re-announcements alongside other updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: (before, current) => `${before} -> ${current}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-03 -> 2026-06-04\n\n/repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebaselines from one coherent source observation", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.make({
|
||||
|
|
@ -213,52 +254,83 @@ describe("SystemContext", () => {
|
|||
update: (_previous, current) => current,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
generation: { baseline: "2026-06-04" },
|
||||
expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not render discarded updates while replacing", () =>
|
||||
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
|
||||
Effect.gen(function* () {
|
||||
let updates = 0
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: () => {
|
||||
updates++
|
||||
return "updated"
|
||||
},
|
||||
key: "core/remote",
|
||||
value: SystemContext.unavailable,
|
||||
baseline: (value) => `Instructions: ${value}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
expect(updates).toBe(0)
|
||||
).toEqual({
|
||||
text: "2026-06-04\n\nInstructions: contents",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
|
||||
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
"core/remote": { value: "instructions", removed: "Instructions removed" },
|
||||
}
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
])
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
// Undecodable belief cannot be restated; removed source entries self-clean.
|
||||
expect(
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: 42 },
|
||||
"core/gone": { value: "gone" },
|
||||
}),
|
||||
).toEqual({ text: "", applied: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("diffs list values by key with a changed comparator", () =>
|
||||
Effect.sync(() => {
|
||||
const previous = [
|
||||
{ name: "effect", description: "Build with Effect" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "retired", description: "Old" },
|
||||
]
|
||||
const current = [
|
||||
{ name: "effect", description: "Build with Effect v4" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "writing", description: "Write prose" },
|
||||
]
|
||||
|
||||
expect(
|
||||
SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(value) => value.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
),
|
||||
).toEqual({
|
||||
added: [{ name: "writing", description: "Write prose" }],
|
||||
removed: [{ name: "retired", description: "Old" }],
|
||||
changed: [
|
||||
{
|
||||
previous: { name: "effect", description: "Build with Effect" },
|
||||
current: { name: "effect", description: "Build with Effect v4" },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -281,7 +353,7 @@ describe("SystemContext", () => {
|
|||
stringContext({ key: "core/date", value: "date" }),
|
||||
stringContext({ key: "core/location", value: "location" }),
|
||||
]),
|
||||
)).baseline,
|
||||
)).text,
|
||||
).toBe("date\n\nlocation")
|
||||
}),
|
||||
)
|
||||
|
|
@ -295,13 +367,13 @@ describe("SystemContext", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("requires namespaced durable snapshot keys", () =>
|
||||
it.effect("requires namespaced applied keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
|
||||
const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied)
|
||||
|
||||
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const entry = (key: string, text: string, sourceKey = key) => ({
|
||||
key: SystemContext.Key.make(key),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make(sourceKey),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(text),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node))
|
||||
|
||||
describe("SystemContextRegistry", () => {
|
||||
it.effect("loads empty system context when there are no entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads scoped entries in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/second", "second"))
|
||||
yield* registry.register(entry("test/first", "first"))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-evaluates entry producers on each load", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.register({
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return SystemContext.empty
|
||||
}),
|
||||
})
|
||||
|
||||
yield* registry.load()
|
||||
yield* registry.load()
|
||||
|
||||
expect(loads).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates entry producer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("entry failed")
|
||||
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate source keys from separate entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/first", "first", "test/duplicate"))
|
||||
yield* registry.register(entry("test/second", "second", "test/duplicate"))
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate entry keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/duplicate", "first"))
|
||||
|
||||
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an entry when its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -508,11 +508,13 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
|||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
const display = thinking.display
|
||||
return {
|
||||
type: "adaptive" as const,
|
||||
...(display === "summarized" || display === "omitted" ? { display } : {}),
|
||||
}
|
||||
const display =
|
||||
thinking.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: thinking.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
||||
if (thinking.type !== "enabled") return undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue