mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 18:03:25 +00:00
feat(core): persist v2 session context epochs
This commit is contained in:
parent
64dc6d39ab
commit
83916f667d
39 changed files with 9303 additions and 80 deletions
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE `session_context_epoch` (
|
||||
`session_id` text PRIMARY KEY,
|
||||
`baseline` text NOT NULL,
|
||||
`checkpoint` text NOT NULL,
|
||||
`baseline_seq` integer NOT NULL,
|
||||
CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
2026
packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json
generated
Normal file
2026
packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `session_context_message` (
|
||||
`session_id` text NOT NULL,
|
||||
`seq` integer NOT NULL,
|
||||
`parts` text NOT NULL,
|
||||
CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`),
|
||||
CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX `session_context_message_session_seq_idx` ON `session_context_message` (`session_id`,`seq`);
|
||||
2113
packages/core/migration/20260604181329_add_session_context_updates/snapshot.json
generated
Normal file
2113
packages/core/migration/20260604181329_add_session_context_updates/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `session_context_epoch` ADD `replacement_pending` integer DEFAULT false NOT NULL;
|
||||
2123
packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json
generated
Normal file
2123
packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer;
|
||||
2133
packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json
generated
Normal file
2133
packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
4
packages/core/src/database/migration.gen.ts
generated
4
packages/core/src/database/migration.gen.ts
generated
|
|
@ -32,5 +32,9 @@ export const migrations = (
|
|||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260604180746_add_session_context_epoch"),
|
||||
import("./migration/20260604181329_add_session_context_updates"),
|
||||
import("./migration/20260604181706_add_session_context_replacement"),
|
||||
import("./migration/20260604181807_add_session_context_replacement_sequence"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604180746_add_session_context_epoch",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`checkpoint\` text NOT NULL,
|
||||
\`baseline_seq\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604181329_add_session_context_updates",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_context_message\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`parts\` text NOT NULL,
|
||||
CONSTRAINT \`session_context_message_pk\` PRIMARY KEY(\`session_id\`, \`seq\`),
|
||||
CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`revision\` integer DEFAULT 0 NOT NULL;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_context_message_session_seq_idx\` ON \`session_context_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604181706_add_session_context_replacement",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_pending\` integer DEFAULT false NOT NULL;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604181807_add_session_context_replacement_sequence",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_seq\` integer;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -40,6 +40,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
|
|||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRunCoordinator } from "./session/run-coordinator"
|
||||
import { SessionSystemContext } from "./session-system-context"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
|
|
@ -60,6 +61,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
SessionSystemContext.locationLayer,
|
||||
permissionsAndTools,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
|
|
|
|||
184
packages/core/src/session/context-epoch.ts
Normal file
184
packages/core/src/session/context-epoch.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SessionSystemContext } from "../session-system-context"
|
||||
import { SystemContext } from "../system-context"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionContextMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
const sameBaseline = Schema.toEquivalence(SystemContext.PartsSchema)
|
||||
const sameCheckpoint = Schema.toEquivalence(SystemContext.CheckpointSchema)
|
||||
|
||||
export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: SessionSystemContext.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const snapshot = yield* context.load()
|
||||
const stored = yield* find(db, sessionID)
|
||||
if (!stored) {
|
||||
const initialized = SystemContext.initialize(snapshot)
|
||||
yield* events.publish(SessionEvent.ContextInitialized, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
baseline: initialized.baseline,
|
||||
checkpoint: initialized.checkpoint,
|
||||
})
|
||||
return initialized.baseline
|
||||
}
|
||||
if (stored.replacement_pending) {
|
||||
const initialized = SystemContext.initialize(snapshot)
|
||||
yield* events.publish(SessionEvent.ContextReplaced, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
expectedRevision: stored.revision,
|
||||
baseline: initialized.baseline,
|
||||
checkpoint: initialized.checkpoint,
|
||||
})
|
||||
return initialized.baseline
|
||||
}
|
||||
|
||||
const refreshed = SystemContext.refresh(snapshot, stored.checkpoint)
|
||||
if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) return stored.baseline
|
||||
yield* events.publish(SessionEvent.ContextUpdated, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
expectedRevision: stored.revision,
|
||||
parts: refreshed.changes,
|
||||
checkpoint: refreshed.checkpoint,
|
||||
})
|
||||
return stored.baseline
|
||||
})
|
||||
|
||||
export 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 projectInitialized = Effect.fn("SessionContextEpoch.projectInitialized")(function* (
|
||||
db: DatabaseService,
|
||||
event: SessionEvent.ContextInitialized,
|
||||
seq: number,
|
||||
) {
|
||||
const stored = yield* find(db, event.data.sessionID)
|
||||
if (stored) {
|
||||
if (stored.baseline_seq > seq) return yield* Effect.void
|
||||
if (stored.baseline_seq !== seq || !sameBaseline(stored.baseline, event.data.baseline))
|
||||
return yield* Effect.die("Session context epoch initialization conflicts with stored baseline")
|
||||
return yield* Effect.void
|
||||
}
|
||||
return yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: event.data.sessionID,
|
||||
baseline: event.data.baseline,
|
||||
checkpoint: event.data.checkpoint,
|
||||
baseline_seq: seq,
|
||||
replacement_pending: false,
|
||||
replacement_seq: null,
|
||||
revision: 0,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(function* (
|
||||
db: DatabaseService,
|
||||
event: SessionEvent.ContextUpdated,
|
||||
seq: number,
|
||||
) {
|
||||
const stored = yield* find(db, event.data.sessionID)
|
||||
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
|
||||
if (stored.replacement_pending) return yield* Effect.die("Session context epoch replacement is pending")
|
||||
if (stored.revision > event.data.expectedRevision) {
|
||||
if (event.data.parts.length === 0) return yield* Effect.void
|
||||
const projected = yield* db
|
||||
.select({ parts: SessionContextMessageTable.parts })
|
||||
.from(SessionContextMessageTable)
|
||||
.where(
|
||||
and(eq(SessionContextMessageTable.session_id, event.data.sessionID), eq(SessionContextMessageTable.seq, seq)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (projected && sameBaseline(projected.parts, event.data.parts)) return yield* Effect.void
|
||||
return yield* Effect.die("Session context update conflicts with stored projection")
|
||||
}
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ checkpoint: event.data.checkpoint, revision: event.data.expectedRevision + 1 })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, event.data.sessionID),
|
||||
eq(SessionContextEpochTable.revision, event.data.expectedRevision),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
|
||||
if (event.data.parts.length === 0) return yield* Effect.void
|
||||
return yield* db
|
||||
.insert(SessionContextMessageTable)
|
||||
.values({ session_id: event.data.sessionID, seq, parts: event.data.parts })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(function* (
|
||||
db: DatabaseService,
|
||||
event: SessionEvent.ContextReplaced,
|
||||
seq: number,
|
||||
) {
|
||||
const stored = yield* find(db, event.data.sessionID)
|
||||
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
|
||||
if (!stored.replacement_pending) {
|
||||
if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void
|
||||
return yield* Effect.die("Session context epoch replacement was not requested")
|
||||
}
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: event.data.baseline,
|
||||
checkpoint: event.data.checkpoint,
|
||||
baseline_seq: seq,
|
||||
replacement_pending: false,
|
||||
replacement_seq: null,
|
||||
revision: event.data.expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, event.data.sessionID),
|
||||
eq(SessionContextEpochTable.revision, event.data.expectedRevision),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
|
||||
return yield* Effect.void
|
||||
})
|
||||
|
||||
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
seq: number,
|
||||
) {
|
||||
const stored = yield* find(db, sessionID)
|
||||
if (!stored || stored.baseline_seq >= seq || stored.replacement_seq === seq) return yield* Effect.void
|
||||
return yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ replacement_pending: true, replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
|
@ -4,14 +4,18 @@ import { Database } from "../database/database"
|
|||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
import { SessionContextEpochTable, SessionContextMessageTable, SessionMessageTable } from "./sql"
|
||||
import type { SystemContext } from "../system-context"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
export type RunnerMessage =
|
||||
| SessionMessage.Message
|
||||
| { readonly type: "system-context"; readonly parts: ReadonlyArray<SystemContext.Part> }
|
||||
|
||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* db
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
|
|
@ -19,7 +23,14 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
|
|||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* db
|
||||
})
|
||||
|
||||
const messageRows = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
compaction: typeof SessionMessageTable.$inferSelect | undefined,
|
||||
) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
|
|
@ -31,17 +42,79 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
|
|||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* Effect.forEach(
|
||||
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
|
||||
decodeMessageRow,
|
||||
)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const messages = yield* messageRows(db, sessionID, compaction)
|
||||
const epoch = yield* db
|
||||
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const updates = yield* db
|
||||
.select()
|
||||
.from(SessionContextMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextMessageTable.session_id, sessionID),
|
||||
epoch ? gt(SessionContextMessageTable.seq, epoch.baselineSeq) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionContextMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(
|
||||
merge(
|
||||
messages.map((row) => ({ type: "message" as const, seq: row.seq, row })),
|
||||
updates.map((row) => ({ type: "system-context" as const, seq: row.seq, row })),
|
||||
),
|
||||
(item): Effect.Effect<RunnerMessage, MessageDecodeError> =>
|
||||
item.type === "message"
|
||||
? decodeMessageRow(item.row)
|
||||
: Effect.succeed({ type: "system-context", parts: item.row.parts }),
|
||||
)
|
||||
})
|
||||
|
||||
function merge<Left extends { readonly seq: number }, Right extends { readonly seq: number }>(
|
||||
left: ReadonlyArray<Left>,
|
||||
right: ReadonlyArray<Right>,
|
||||
): Array<Left | Right> {
|
||||
const result: Array<Left | Right> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length || rightIndex < right.length) {
|
||||
if (rightIndex >= right.length || (leftIndex < left.length && left[leftIndex].seq < right[rightIndex].seq)) {
|
||||
result.push(left[leftIndex])
|
||||
leftIndex++
|
||||
continue
|
||||
}
|
||||
result.push(right[rightIndex])
|
||||
rightIndex++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export * as SessionContext from "./context"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SessionSchema } from "./schema"
|
|||
import { Location } from "../location"
|
||||
import { RelativePath } from "../schema"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SystemContext } from "../system-context"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
|
|
@ -119,6 +120,41 @@ export namespace PromptLifecycle {
|
|||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const ContextInitialized = EventV2.define({
|
||||
type: "session.next.context.initialized",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
baseline: SystemContext.PartsSchema,
|
||||
checkpoint: SystemContext.CheckpointSchema,
|
||||
},
|
||||
})
|
||||
export type ContextInitialized = typeof ContextInitialized.Type
|
||||
|
||||
export const ContextUpdated = EventV2.define({
|
||||
type: "session.next.context.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
expectedRevision: NonNegativeInt,
|
||||
parts: SystemContext.PartsSchema,
|
||||
checkpoint: SystemContext.CheckpointSchema,
|
||||
},
|
||||
})
|
||||
export type ContextUpdated = typeof ContextUpdated.Type
|
||||
|
||||
export const ContextReplaced = EventV2.define({
|
||||
type: "session.next.context.replaced",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
expectedRevision: NonNegativeInt,
|
||||
baseline: SystemContext.PartsSchema,
|
||||
checkpoint: SystemContext.CheckpointSchema,
|
||||
},
|
||||
})
|
||||
export type ContextReplaced = typeof ContextReplaced.Type
|
||||
|
||||
export const Synthetic = EventV2.define({
|
||||
type: "session.next.synthetic",
|
||||
...options,
|
||||
|
|
@ -444,6 +480,9 @@ const DurableDefinitions = [
|
|||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
ContextInitialized,
|
||||
ContextUpdated,
|
||||
ContextReplaced,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.context.initialized": () => Effect.void,
|
||||
"session.next.context.updated": () => Effect.void,
|
||||
"session.next.context.replaced": () => Effect.void,
|
||||
"session.next.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Synthetic({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
|
|
@ -352,12 +353,18 @@ export const layer = Layer.effectDiscard(
|
|||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -413,6 +420,18 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.ContextInitialized, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return SessionContextEpoch.projectInitialized(db, event, event.seq)
|
||||
})
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return SessionContextEpoch.projectUpdated(db, event, event.seq)
|
||||
})
|
||||
yield* events.project(SessionEvent.ContextReplaced, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return SessionContextEpoch.projectReplaced(db, event, event.seq)
|
||||
})
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
|
|
@ -432,7 +451,12 @@ export const layer = Layer.effectDiscard(
|
|||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return run(db, event).pipe(
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
|
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
|
|||
import { Database } from "../../database/database"
|
||||
import { SessionInput } from "../input"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SessionSystemContext } from "../../session-system-context"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
|
|
@ -85,6 +87,7 @@ export const layer = Layer.effect(
|
|||
const tools = yield* ToolRegistry.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const systemContext = yield* SessionSystemContext.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
|
|
@ -95,6 +98,9 @@ export const layer = Layer.effect(
|
|||
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||
return yield* store.context(sessionID)
|
||||
})
|
||||
const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (sessionID: SessionSchema.ID) {
|
||||
return yield* store.runnerContext(sessionID)
|
||||
})
|
||||
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -141,8 +147,14 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
yield* failInterruptedTools(session.id)
|
||||
const context = yield* getContext(session.id)
|
||||
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
|
||||
const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)
|
||||
const context = yield* getRunnerContext(session.id)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: system.map((part) => SystemPart.make(part.text)),
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: yield* tools.definitions(),
|
||||
})
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: session.agent ?? "build",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from "@opencode-ai/llm"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "../prompt"
|
||||
import { SessionContext } from "../context"
|
||||
import { SystemContext } from "../../system-context"
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
type: "media",
|
||||
|
|
@ -91,7 +93,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
|
|
@ -131,9 +133,11 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
case "system-context":
|
||||
return [Message.system(SystemContext.render(message.parts))]
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
|
||||
export const toLLMMessages = (messages: readonly SessionContext.RunnerMessage[], model: Model) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema"
|
|||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { SystemContext } from "../system-context"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
|
|
@ -161,3 +162,32 @@ export const SessionInputTable = sqliteTable(
|
|||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
|
||||
checkpoint: text({ mode: "json" }).notNull().$type<SystemContext.Checkpoint>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
replacement_pending: integer({ mode: "boolean" }).notNull().default(false),
|
||||
replacement_seq: integer(),
|
||||
revision: integer().notNull().default(0),
|
||||
})
|
||||
|
||||
export const SessionContextMessageTable = sqliteTable(
|
||||
"session_context_message",
|
||||
{
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
parts: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.session_id, table.seq] }),
|
||||
index("session_context_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ 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,
|
||||
) => Effect.Effect<SessionContext.RunnerMessage[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
|
|
@ -34,6 +37,9 @@ export const layer = Layer.effect(
|
|||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionContext.load(db, sessionID)
|
||||
}),
|
||||
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID) {
|
||||
return yield* SessionContext.loadForRunner(db, sessionID)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ export interface Part {
|
|||
readonly text: string
|
||||
}
|
||||
|
||||
export const PartSchema = Schema.Struct({
|
||||
key: Key,
|
||||
text: Schema.String,
|
||||
})
|
||||
export const PartsSchema = Schema.Array(PartSchema)
|
||||
export const CheckpointSchema = Schema.Record(Schema.String, Schema.String)
|
||||
|
||||
export type Checkpoint = Readonly<Record<string, string>>
|
||||
|
||||
export interface Initialized {
|
||||
|
|
@ -105,11 +112,18 @@ export function initialize(snapshot: Snapshot): Initialized {
|
|||
|
||||
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
|
||||
return {
|
||||
changes: snapshot.entries.flatMap((entry) =>
|
||||
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
|
||||
? [{ key: entry.key, text: entry.update }]
|
||||
: [],
|
||||
),
|
||||
changes: [
|
||||
...snapshot.entries.flatMap((entry) =>
|
||||
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
|
||||
? [{ key: entry.key, text: entry.update }]
|
||||
: [],
|
||||
),
|
||||
...Object.keys(previous).flatMap((key) =>
|
||||
snapshot.entries.some((entry) => entry.key === key)
|
||||
? []
|
||||
: [{ key: Key.make(key), text: `System context component removed: ${key}` }],
|
||||
),
|
||||
],
|
||||
checkpoint: nextCheckpoint(snapshot, previous),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,15 +63,22 @@ describe("DatabaseMigration", () => {
|
|||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
||||
).toEqual({ name: "session_context_epoch" })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
|
||||
).toEqual({ name: "session_context_message" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 34 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_context_message_session_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_admitted_seq_idx" },
|
||||
{ name: "session_context_message_session_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_input_session_promoted_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Recorded context\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { DateTime } from "effect"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
|
|
@ -86,6 +87,15 @@ describe("toLLMMessages", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("maps hidden Session context updates into chronological system messages", () => {
|
||||
expect(
|
||||
toLLMMessages(
|
||||
[{ type: "system-context", parts: [{ key: SystemContext.Key.make("test/context"), text: "Updated context" }] }],
|
||||
model,
|
||||
),
|
||||
).toEqual([Message.system("Updated context")])
|
||||
})
|
||||
|
||||
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
|
@ -55,6 +58,23 @@ const model = OpenAIChat.route
|
|||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContext = Layer.succeed(
|
||||
SessionSystemContext.Service,
|
||||
SessionSystemContext.Service.of({
|
||||
load: () =>
|
||||
Effect.succeed({
|
||||
entries: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
key: SystemContext.Key.make("test/context"),
|
||||
baseline: "Recorded context",
|
||||
update: "Recorded context",
|
||||
hash: Hash.sha256("Recorded context"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
|
@ -62,6 +82,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
|
|||
Layer.provide(client),
|
||||
Layer.provide(registry),
|
||||
Layer.provide(models),
|
||||
Layer.provide(systemContext),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
const execution = Layer.effect(
|
||||
|
|
@ -88,6 +109,7 @@ const it = testEffect(
|
|||
permission,
|
||||
registry,
|
||||
models,
|
||||
systemContext,
|
||||
runner,
|
||||
coordinator,
|
||||
execution,
|
||||
|
|
@ -145,6 +167,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
).toEqual([
|
||||
"session.next.prompt.admitted.1",
|
||||
"session.next.prompt.promoted.1",
|
||||
"session.next.context.initialized.1",
|
||||
"session.next.step.started.1",
|
||||
"session.next.text.started.1",
|
||||
"session.next.text.ended.1",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
|||
import { NativeTool } from "@opencode-ai/core/tool/native"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
|
|
@ -135,6 +138,28 @@ const echo = Layer.effectDiscard(
|
|||
),
|
||||
).pipe(Layer.provide(registry))
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContextKey = SystemContext.Key.make("test/context")
|
||||
let systemBaseline = "Initial context"
|
||||
let systemRemoved = false
|
||||
const systemContext = Layer.succeed(
|
||||
SessionSystemContext.Service,
|
||||
SessionSystemContext.Service.of({
|
||||
load: () =>
|
||||
Effect.sync(() => ({
|
||||
entries: systemRemoved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
key: systemContextKey,
|
||||
baseline: systemBaseline,
|
||||
update: systemBaseline,
|
||||
hash: Hash.sha256(systemBaseline),
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
|
@ -142,6 +167,7 @@ const runner = SessionRunnerLLM.layer.pipe(
|
|||
Layer.provide(client),
|
||||
Layer.provide(registry),
|
||||
Layer.provide(models),
|
||||
Layer.provide(systemContext),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
const execution = Layer.effect(
|
||||
|
|
@ -170,6 +196,7 @@ const it = testEffect(
|
|||
registry,
|
||||
echo,
|
||||
models,
|
||||
systemContext,
|
||||
runner,
|
||||
coordinator,
|
||||
execution,
|
||||
|
|
@ -200,6 +227,8 @@ const insertSession = (id: SessionV2.ID) =>
|
|||
const setup = Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
response = []
|
||||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
responseStream = undefined
|
||||
|
|
@ -511,6 +540,173 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses one durable baseline after the context producer changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["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(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.initialized.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.updated.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("admits removed context as a hidden chronological tombstone", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemRemoved = true
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ 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([
|
||||
{ type: "text", text: "System context component removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
text: "summary",
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects reasoning and tool events without executing or continuing tools", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ describe("SystemContext", () => {
|
|||
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
|
||||
})
|
||||
|
||||
test("drops checkpoints for removed components", async () => {
|
||||
test("emits tombstones and drops checkpoints for removed components", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
|
|
@ -126,7 +126,7 @@ describe("SystemContext", () => {
|
|||
})
|
||||
|
||||
expect(refreshed).toEqual({
|
||||
changes: [],
|
||||
changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }],
|
||||
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -351,38 +351,17 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
|||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
|
||||
}
|
||||
|
||||
const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
const last = message.content.at(-1)
|
||||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true
|
||||
}
|
||||
|
||||
const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* (
|
||||
messages: LLMRequest["messages"],
|
||||
index: number,
|
||||
) {
|
||||
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
|
||||
const previous = messages[index - 1]
|
||||
const next = messages[index + 1]
|
||||
if (!previous)
|
||||
return yield* invalid(
|
||||
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system",
|
||||
)
|
||||
if (previous.role === "system")
|
||||
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
|
||||
if (endsInLocalToolUse(previous))
|
||||
return yield* invalid(
|
||||
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
|
||||
)
|
||||
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
|
||||
return yield* invalid(
|
||||
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use",
|
||||
)
|
||||
if (next?.role === "system")
|
||||
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
|
||||
if (next && next.role !== "assistant")
|
||||
return yield* invalid(
|
||||
"Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message",
|
||||
)
|
||||
})
|
||||
return (
|
||||
previous !== undefined &&
|
||||
previous.role !== "system" &&
|
||||
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
|
||||
next?.role !== "system" &&
|
||||
(next === undefined || next.role === "assistant")
|
||||
)
|
||||
}
|
||||
|
||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
|
|
@ -407,8 +386,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
if (supportsNativeSystemUpdates(request)) {
|
||||
yield* validateNativeSystemUpdate(request.messages, index)
|
||||
yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages[index - 1])
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
|
||||
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,8 +292,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
|||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages[index - 1])
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
|
||||
const content = textWithCache(breakpoints, part.text, part.cache)
|
||||
const previous = messages.at(-1)
|
||||
|
|
|
|||
|
|
@ -200,8 +200,9 @@ const lowerToolCall = (part: ToolCallPart) => ({
|
|||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages[index - 1])
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user")
|
||||
|
|
|
|||
|
|
@ -252,8 +252,9 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
|||
const system: OpenAIChatMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
for (const message of request.messages) {
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages[index - 1])
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
|
|
|
|||
|
|
@ -338,8 +338,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||
const input: OpenAIResponsesInputItem[] = [...system]
|
||||
const store = OpenAIOptions.store(request)
|
||||
|
||||
for (const message of request.messages) {
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages[index - 1])
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
|
||||
const previous = input.at(-1)
|
||||
if (previous && "role" in previous && previous.role === "user")
|
||||
|
|
|
|||
|
|
@ -177,6 +177,20 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
|
|||
return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache }
|
||||
})
|
||||
|
||||
export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* (
|
||||
route: string,
|
||||
previous: LLMRequest["messages"][number] | undefined,
|
||||
) {
|
||||
if (
|
||||
previous?.role === "assistant" &&
|
||||
previous.content.some((part) => part.type === "tool-call" && part.providerExecuted !== true)
|
||||
)
|
||||
return yield* invalidRequest(
|
||||
`${route} chronological system updates cannot appear between a local tool call and its tool result`,
|
||||
)
|
||||
return yield* Effect.void
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse the streamed JSON input of a tool call. Treats an empty string as
|
||||
* `"{}"` — providers occasionally finish a tool call without ever emitting
|
||||
|
|
|
|||
|
|
@ -125,22 +125,56 @@ describe("Anthropic Messages route", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid native chronological system update placement", () =>
|
||||
it.effect("falls back for unsupported native chronological system update placement", () =>
|
||||
Effect.gen(function* () {
|
||||
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) =>
|
||||
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
|
||||
|
||||
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
|
||||
expect(
|
||||
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
|
||||
).toContain("cannot be consecutive")
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)).body.messages,
|
||||
).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "Plain." }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "<system-update>\nAfter plain assistant.\n</system-update>" }],
|
||||
},
|
||||
])
|
||||
expect(
|
||||
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message,
|
||||
).toContain("must follow a user message, tool result, or assistant server tool use")
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
|
||||
)).body.messages,
|
||||
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
|
||||
expect(
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)).body.messages,
|
||||
).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Before." },
|
||||
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
|
||||
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(
|
||||
(yield* placementError([
|
||||
Message.user("Use the tool."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
||||
{ type: "text", text: "Waiting." },
|
||||
]),
|
||||
Message.system("Too early."),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
])).message,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
|
||||
import {
|
||||
ContentPart,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ToolCallPart,
|
||||
Usage,
|
||||
} from "../src/schema"
|
||||
import { ProviderShared } from "../src/protocols/shared"
|
||||
|
||||
const model = new Model({
|
||||
|
|
@ -54,6 +64,17 @@ describe("llm schema", () => {
|
|||
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
|
||||
})
|
||||
|
||||
test("rejects chronological system updates between a local tool call and its result", async () => {
|
||||
const previous = Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
||||
{ type: "text", text: "Waiting." },
|
||||
])
|
||||
|
||||
await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", previous))).rejects.toThrow(
|
||||
"Test chronological system updates cannot appear between a local tool call and its tool result",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects invalid event type", () => {
|
||||
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -686,3 +686,65 @@ Compatibility:
|
|||
|
||||
- Foreground V2 bash execution is unchanged.
|
||||
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
|
||||
|
||||
## 2026-06-04: Initialize Durable Session Context Epochs
|
||||
|
||||
Affected schema:
|
||||
|
||||
- Add synchronized `session.next.context.initialized.1` Session events.
|
||||
- Add `session_context_epoch` for one active immutable keyed baseline, component-hash checkpoint, and baseline sequence per Session.
|
||||
|
||||
Change:
|
||||
|
||||
- Lazily initialize one durable Context Epoch at the first safe provider-turn boundary.
|
||||
- Lower its exact keyed baseline parts through `LLMRequest.system` for every provider turn in the epoch.
|
||||
- Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context.
|
||||
- Keep ordinary Session transcript APIs unchanged.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- This adds one database migration and one synchronized Session event type.
|
||||
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||
- Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
||||
|
||||
## 2026-06-04: Admit Chronological Session Context Updates
|
||||
|
||||
Affected schema:
|
||||
|
||||
- Add synchronized `session.next.context.updated.1` Session events.
|
||||
- Add `session_context_epoch.revision` for transactional checkpoint advancement.
|
||||
- Add `session_context_message` for hidden chronological keyed context updates ordered by Session aggregate sequence.
|
||||
|
||||
Change:
|
||||
|
||||
- Refresh Location-scoped Context Components at each safe provider-turn boundary.
|
||||
- Keep the stored baseline immutable while admitting changed component values as runner-private chronological `Message.system(...)` history.
|
||||
- Advance component-hash checkpoints transactionally even when a removed component produces no visible update text.
|
||||
- Keep ordinary Session transcript APIs unchanged while runner history merges visible Session messages and hidden context updates by durable aggregate sequence.
|
||||
- Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- This adds one database migration and one synchronized Session event type.
|
||||
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||
- Replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
||||
|
||||
## 2026-06-04: Replace Session Context Epochs Lazily
|
||||
|
||||
Affected schema:
|
||||
|
||||
- Add synchronized `session.next.context.replaced.1` Session events.
|
||||
- Add `session_context_epoch.replacement_pending` and `session_context_epoch.replacement_seq` for idempotent lazy replacement requests.
|
||||
|
||||
Change:
|
||||
|
||||
- Mark the active Context Epoch for replacement after a model switch or completed compaction projection.
|
||||
- Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement.
|
||||
- Render and persist the fresh immutable baseline lazily at the next safe provider-turn boundary.
|
||||
- Exclude hidden chronological updates from earlier epochs when assembling active provider history.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- This adds two additive database migrations and one synchronized Session event type.
|
||||
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||
- Compaction execution, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue