fix(core): enforce one step settlement
This commit is contained in:
parent
59e8dbb066
commit
db664db5f3
8 changed files with 71 additions and 43 deletions
|
|
@ -571,7 +571,7 @@ export type SessionsContextOutput = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||||
readonly finish?: string
|
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
readonly cost?: number
|
readonly cost?: number
|
||||||
readonly tokens?: {
|
readonly tokens?: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -770,7 +770,7 @@ export type SessionsEventsOutput =
|
||||||
readonly timestamp: number
|
readonly timestamp: number
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly finish: string
|
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
readonly cost: number
|
readonly cost: number
|
||||||
readonly tokens: {
|
readonly tokens: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -1196,7 +1196,7 @@ export type SessionsMessageOutput = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||||
readonly finish?: string
|
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
readonly cost?: number
|
readonly cost?: number
|
||||||
readonly tokens?: {
|
readonly tokens?: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,7 @@ export const layer = Layer.effect(
|
||||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||||
}
|
}
|
||||||
const stepSettlement = publisher.stepSettlement()
|
const stepSettlement = publisher.stepSettlement()
|
||||||
if (stepSettlement && !publisher.hasProviderError()) {
|
if (stepSettlement && !publisher.hasProviderError() && !publisher.hasAssistantSettled()) {
|
||||||
const endSnapshot = yield* snapshots.capture()
|
const endSnapshot = yield* snapshots.capture()
|
||||||
const files =
|
const files =
|
||||||
startSnapshot && endSnapshot
|
startSnapshot && endSnapshot
|
||||||
|
|
|
||||||
|
|
@ -66,15 +66,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
>()
|
>()
|
||||||
const timestamp = DateTime.now
|
const timestamp = DateTime.now
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
let assistantActive = false
|
|
||||||
let assistantSettled = false
|
let assistantSettled = false
|
||||||
let providerFailed = false
|
let providerFailed = false
|
||||||
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
|
let stepSettlement: { readonly finish: SessionMessage.Finish; readonly tokens: ReturnType<typeof tokens> } | undefined
|
||||||
|
|
||||||
const startAssistant = Effect.fnUntraced(function* () {
|
const startAssistant = Effect.fnUntraced(function* () {
|
||||||
if (assistantMessageID !== undefined) return assistantMessageID
|
if (assistantMessageID !== undefined) return assistantMessageID
|
||||||
assistantMessageID = SessionMessage.ID.create()
|
assistantMessageID = SessionMessage.ID.create()
|
||||||
assistantActive = true
|
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
...input,
|
...input,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
|
|
@ -196,32 +194,38 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
yield* flushFragments()
|
yield* flushFragments()
|
||||||
})
|
})
|
||||||
|
|
||||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
const settleAssistant = Effect.fnUntraced(function* (
|
||||||
|
publish: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>,
|
||||||
|
) {
|
||||||
if (assistantSettled) return
|
if (assistantSettled) return
|
||||||
yield* flush()
|
yield* flush()
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
assistantActive = false
|
|
||||||
assistantSettled = true
|
assistantSettled = true
|
||||||
yield* events.publish(SessionEvent.Step.Failed, {
|
yield* publish(assistantMessageID)
|
||||||
sessionID: input.sessionID,
|
|
||||||
timestamp: yield* timestamp,
|
|
||||||
assistantMessageID,
|
|
||||||
error: { type: "unknown", message },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const interruptAssistant = Effect.fnUntraced(function* () {
|
const failAssistant = (message: string) =>
|
||||||
if (assistantSettled) return
|
settleAssistant((assistantMessageID) =>
|
||||||
yield* flush()
|
Effect.gen(function* () {
|
||||||
const assistantMessageID = yield* startAssistant()
|
yield* events.publish(SessionEvent.Step.Failed, {
|
||||||
assistantActive = false
|
sessionID: input.sessionID,
|
||||||
assistantSettled = true
|
timestamp: yield* timestamp,
|
||||||
yield* events.publish(SessionEvent.Step.Interrupted, {
|
assistantMessageID,
|
||||||
sessionID: input.sessionID,
|
error: { type: "unknown", message },
|
||||||
timestamp: yield* timestamp,
|
})
|
||||||
assistantMessageID,
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
const interruptAssistant = () =>
|
||||||
|
settleAssistant((assistantMessageID) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* events.publish(SessionEvent.Step.Interrupted, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
timestamp: yield* timestamp,
|
||||||
|
assistantMessageID,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||||
message: string,
|
message: string,
|
||||||
|
|
@ -408,7 +412,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
}
|
}
|
||||||
case "step-finish":
|
case "step-finish":
|
||||||
yield* flush()
|
yield* flush()
|
||||||
assistantActive = false
|
|
||||||
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
|
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
|
||||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||||
return
|
return
|
||||||
|
|
@ -427,8 +430,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
failAssistant,
|
failAssistant,
|
||||||
interruptAssistant,
|
interruptAssistant,
|
||||||
failUnsettledTools,
|
failUnsettledTools,
|
||||||
hasActiveAssistant: () => assistantActive,
|
|
||||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||||
|
hasAssistantSettled: () => assistantSettled,
|
||||||
hasProviderError: () => providerFailed,
|
hasProviderError: () => providerFailed,
|
||||||
stepSettlement: () => stepSettlement,
|
stepSettlement: () => stepSettlement,
|
||||||
startAssistant,
|
startAssistant,
|
||||||
|
|
|
||||||
|
|
@ -543,13 +543,22 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||||
yield* Deferred.await(streamed)
|
yield* Deferred.await(streamed)
|
||||||
yield* Fiber.interrupt(fiber)
|
yield* Fiber.interrupt(fiber)
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const interrupted = yield* db
|
const interruptedVersion = SessionEvent.Step.Interrupted.durable?.version
|
||||||
|
expect(interruptedVersion).toBe(2)
|
||||||
|
if (interruptedVersion === undefined) return yield* Effect.die("Step.Interrupted must be durable")
|
||||||
|
const settlements = yield* db
|
||||||
.select({ type: EventTable.type })
|
.select({ type: EventTable.type })
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(eq(EventTable.type, EventV2.versionedType(SessionEvent.Step.Interrupted.type, 1)))
|
.where(eq(EventTable.aggregate_id, sessionID))
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
expect(interrupted).toHaveLength(1)
|
expect(
|
||||||
|
settlements.filter(({ type }) =>
|
||||||
|
[SessionEvent.Step.Ended.type, SessionEvent.Step.Failed.type, SessionEvent.Step.Interrupted.type].some((settled) =>
|
||||||
|
type.startsWith(settled),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual([{ type: EventV2.versionedType(SessionEvent.Step.Interrupted.type, interruptedVersion) }])
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
{ type: "user", text: prompt },
|
{ type: "user", text: prompt },
|
||||||
{
|
{
|
||||||
|
|
@ -2660,6 +2669,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
finish: "interrupted",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
@ -2804,6 +2814,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
finish: "interrupted",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export namespace Step {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
finish: Schema.String,
|
finish: SessionMessage.Finish,
|
||||||
cost: Schema.Finite,
|
cost: Schema.Finite,
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
|
|
@ -195,7 +195,7 @@ export namespace Step {
|
||||||
|
|
||||||
export const Interrupted = Event.define({
|
export const Interrupted = Event.define({
|
||||||
type: "session.next.step.interrupted",
|
type: "session.next.step.interrupted",
|
||||||
...options,
|
...stepSettlementOptions,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,17 @@ export const UnknownError = Schema.Struct({
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}).annotate({ identifier: "Session.Error.Unknown" })
|
}).annotate({ identifier: "Session.Error.Unknown" })
|
||||||
|
|
||||||
|
export const Finish = Schema.Literals([
|
||||||
|
"stop",
|
||||||
|
"length",
|
||||||
|
"tool-calls",
|
||||||
|
"content-filter",
|
||||||
|
"error",
|
||||||
|
"unknown",
|
||||||
|
"interrupted",
|
||||||
|
])
|
||||||
|
export type Finish = typeof Finish.Type
|
||||||
|
|
||||||
const Base = {
|
const Base = {
|
||||||
id: ID,
|
id: ID,
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
|
|
@ -169,7 +180,7 @@ export const Assistant = Schema.Struct({
|
||||||
end: Schema.String.pipe(optional),
|
end: Schema.String.pipe(optional),
|
||||||
files: Schema.Array(RelativePath).pipe(optional),
|
files: Schema.Array(RelativePath).pipe(optional),
|
||||||
}).pipe(optional),
|
}).pipe(optional),
|
||||||
finish: Schema.String.pipe(optional),
|
finish: Finish.pipe(optional),
|
||||||
cost: Schema.Finite.pipe(optional),
|
cost: Schema.Finite.pipe(optional),
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,14 @@ describe("public event manifest", () => {
|
||||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||||
expect(EventManifest.Definitions.slice(44, 47)).toEqual([
|
const partDelta = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta)
|
||||||
|
expect(partDelta).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(EventManifest.Definitions.slice(partDelta, partDelta + 3)).toEqual([
|
||||||
SessionV1.Event.PartDelta,
|
SessionV1.Event.PartDelta,
|
||||||
SessionV1.Event.Diff,
|
SessionV1.Event.Diff,
|
||||||
SessionV1.Event.Error,
|
SessionV1.Event.Error,
|
||||||
])
|
])
|
||||||
|
expect(EventManifest.Latest.get("session.next.step.interrupted")).toBe(SessionEvent.Step.Interrupted)
|
||||||
expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
|
expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
|
||||||
expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
|
expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -931,7 +931,7 @@ export type GlobalEvent = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
finish: string
|
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost: number
|
cost: number
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number
|
input: number
|
||||||
|
|
@ -3378,7 +3378,7 @@ export type SyncEventSessionNextStepEnded = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
finish: string
|
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost: number
|
cost: number
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number
|
input: number
|
||||||
|
|
@ -3416,7 +3416,7 @@ export type SyncEventSessionNextStepInterrupted = {
|
||||||
type: "sync"
|
type: "sync"
|
||||||
id: string
|
id: string
|
||||||
syncEvent: {
|
syncEvent: {
|
||||||
type: "session.next.step.interrupted.1"
|
type: "session.next.step.interrupted.2"
|
||||||
id: string
|
id: string
|
||||||
seq: number
|
seq: number
|
||||||
aggregateID: string
|
aggregateID: string
|
||||||
|
|
@ -4022,7 +4022,7 @@ export type SessionMessageAssistant = {
|
||||||
end?: string
|
end?: string
|
||||||
files?: Array<string>
|
files?: Array<string>
|
||||||
}
|
}
|
||||||
finish?: string
|
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost?: number
|
cost?: number
|
||||||
tokens?: {
|
tokens?: {
|
||||||
input: number
|
input: number
|
||||||
|
|
@ -4281,7 +4281,7 @@ export type SessionNextStepEnded = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
finish: string
|
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost: number
|
cost: number
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number
|
input: number
|
||||||
|
|
@ -5355,7 +5355,7 @@ export type V2EventSessionNextStepEnded = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
finish: string
|
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost: number
|
cost: number
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number
|
input: number
|
||||||
|
|
@ -6970,7 +6970,7 @@ export type EventSessionNextStepEnded = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
finish: string
|
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted"
|
||||||
cost: number
|
cost: number
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number
|
input: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue