refactor(core): clean up callModel readability (#38706)
This commit is contained in:
parent
835149e42b
commit
6e4a972bb9
1 changed files with 145 additions and 139 deletions
|
|
@ -35,6 +35,33 @@ type CallOutcome = Data.TaggedEnum<{
|
||||||
}>
|
}>
|
||||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||||
|
|
||||||
|
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||||
|
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
||||||
|
cause.reasons.some(
|
||||||
|
(reason) =>
|
||||||
|
Cause.isDieReason(reason) &&
|
||||||
|
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies how the owned tool fibers ended. Interrupts and interactive declines abort
|
||||||
|
* the step; a defect from a tool implementation becomes a failed tool call the model can
|
||||||
|
* read; a typed infrastructure failure must fail the assistant and then the drain.
|
||||||
|
*/
|
||||||
|
const classifyToolExits = (settled: Exit.Exit<Array<Exit.Exit<void, ToolOutputStore.Error>>, never>) => {
|
||||||
|
const causes =
|
||||||
|
settled._tag === "Failure"
|
||||||
|
? [settled.cause]
|
||||||
|
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||||
|
const failure = causes.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||||
|
return {
|
||||||
|
interrupted: causes.some(Cause.hasInterrupts),
|
||||||
|
declined: causes.some(isUserDeclined),
|
||||||
|
failure,
|
||||||
|
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -52,43 +79,87 @@ const layer = Layer.effect(
|
||||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||||
const titleStarted = new Set<SessionSchema.ID>()
|
const titleStarted = new Set<SessionSchema.ID>()
|
||||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
/**
|
||||||
const session = yield* store.get(sessionID)
|
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||||
return session
|
*/
|
||||||
|
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly force: boolean
|
||||||
|
}) {
|
||||||
|
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
|
||||||
|
yield* settleStaleToolCalls(input.sessionID)
|
||||||
|
yield* runPendingCompaction(input.sessionID)
|
||||||
|
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
|
||||||
|
do {
|
||||||
|
yield* runSteps(input.sessionID)
|
||||||
|
} while (yield* SessionPending.has(db, input.sessionID, "input"))
|
||||||
})
|
})
|
||||||
/** Fires title generation once per process after the first step makes a user message visible. */
|
|
||||||
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
/**
|
||||||
if (titleStarted.has(sessionID)) return
|
* Runs logical steps until no tool result or newly admitted steer requires another
|
||||||
titleStarted.add(sessionID)
|
* model call. Queued inputs remain pending until the current model work reaches idle.
|
||||||
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
|
*/
|
||||||
})
|
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
|
||||||
/** Closes stale tool calls left active by an earlier interrupted drain. */
|
// Fresh work may promote queued input; later steps absorb steers only.
|
||||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
let promotable: SessionPending.Promotable = "input"
|
||||||
sessionID: SessionSchema.ID,
|
let step = 1
|
||||||
) {
|
while (true) {
|
||||||
for (const message of yield* store.context(sessionID)) {
|
const result = yield* runStep(sessionID, promotable, step)
|
||||||
if (message.type !== "assistant") continue
|
yield* startTitleOnce(sessionID)
|
||||||
for (const tool of message.content) {
|
yield* runPendingCompaction(sessionID)
|
||||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
promotable = "steer"
|
||||||
sessionID,
|
step = result.step + 1
|
||||||
assistantMessageID: message.id,
|
|
||||||
callID: tool.id,
|
|
||||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
|
||||||
executed: tool.executed === true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
|
||||||
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
const runStep = Effect.fnUntraced(function* (
|
||||||
cause.reasons.some(
|
sessionID: SessionSchema.ID,
|
||||||
(reason) =>
|
promotable: SessionPending.Promotable,
|
||||||
Cause.isDieReason(reason) &&
|
step: number,
|
||||||
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
|
) {
|
||||||
)
|
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(events, sessionID))
|
||||||
|
/**
|
||||||
|
* Consumes one retry allowance: sleeps the scheduled backoff and reports what the next
|
||||||
|
* attempt should reuse, or publishes Step.Failed and fails once attempts are exhausted.
|
||||||
|
* The step loop performs the retry itself on the next iteration.
|
||||||
|
*/
|
||||||
|
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
|
||||||
|
retry(failure).pipe(
|
||||||
|
Effect.as(CallOutcome.Retry({ step: failure.step, assistantMessageID: failure.assistantMessageID })),
|
||||||
|
Pull.catchDone(() =>
|
||||||
|
events
|
||||||
|
.publish(SessionEvent.Step.Failed, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: failure.assistantMessageID,
|
||||||
|
error: failure.error,
|
||||||
|
})
|
||||||
|
.pipe(Effect.andThen(Effect.fail(failure.cause))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
let currentPromotable: SessionPending.Promotable | undefined = promotable
|
||||||
|
let currentStep = step
|
||||||
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
|
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||||
|
let recoverOverflow = true
|
||||||
|
while (true) {
|
||||||
|
const outcome = yield* callModel(
|
||||||
|
sessionID,
|
||||||
|
currentPromotable,
|
||||||
|
currentStep,
|
||||||
|
recoverOverflow,
|
||||||
|
assistantMessageID,
|
||||||
|
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||||
|
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||||
|
if (outcome._tag === "Retry") assistantMessageID = outcome.assistantMessageID
|
||||||
|
if (outcome._tag === "Restart" && outcome.recoveredOverflow) recoverOverflow = false
|
||||||
|
// Neither a retry nor a compaction restart re-promotes input.
|
||||||
|
currentPromotable = undefined
|
||||||
|
currentStep = outcome.step
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares and runs at most one model call, executes its local tools, and durably
|
* Prepares and runs at most one model call, executes its local tools, and durably
|
||||||
|
|
@ -105,11 +176,9 @@ const layer = Layer.effect(
|
||||||
// Establish what the model knows before admitting what the user said, so
|
// Establish what the model knows before admitting what the user said, so
|
||||||
// a blocked first step leaves pending inputs untouched.
|
// a blocked first step leaves pending inputs untouched.
|
||||||
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
|
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
|
||||||
let currentStep = step
|
const promoted = promotable ? yield* SessionPending.promote(db, events, selected.session.id, promotable) : 0
|
||||||
if (promotable) {
|
// Promoted input opens a fresh step allowance.
|
||||||
const promoted = yield* SessionPending.promote(db, events, selected.session.id, promotable)
|
const currentStep = promoted > 0 ? 1 : step
|
||||||
if (promoted > 0) currentStep = 1
|
|
||||||
}
|
|
||||||
const loaded = yield* context.load(selected)
|
const loaded = yield* context.load(selected)
|
||||||
const { session, agent } = loaded
|
const { session, agent } = loaded
|
||||||
const resolved = loaded.model
|
const resolved = loaded.model
|
||||||
|
|
@ -119,7 +188,8 @@ const layer = Layer.effect(
|
||||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
||||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||||
const compacted = yield* compaction.compact(compactionInput)
|
const compacted = yield* compaction.compact(compactionInput)
|
||||||
if (compacted.status === "completed") return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
if (compacted.status === "completed")
|
||||||
|
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||||
return yield* new StepFailedError({ error: compacted.error })
|
return yield* new StepFailedError({ error: compacted.error })
|
||||||
}
|
}
|
||||||
const prepared = yield* modelRequests.prepare({
|
const prepared = yield* modelRequests.prepare({
|
||||||
|
|
@ -236,8 +306,7 @@ const layer = Layer.effect(
|
||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(compaction.compact({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||||
.status === "completed"
|
|
||||||
)
|
)
|
||||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||||
|
|
||||||
|
|
@ -267,30 +336,17 @@ const layer = Layer.effect(
|
||||||
const settled = yield* restore(
|
const settled = yield* restore(
|
||||||
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||||
).pipe(Effect.exit)
|
).pipe(Effect.exit)
|
||||||
const settledCauses =
|
|
||||||
settled._tag === "Failure"
|
|
||||||
? [settled.cause]
|
|
||||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
|
||||||
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
|
||||||
const userDeclined = settledCauses.some(isUserDeclined)
|
|
||||||
|
|
||||||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
const tools = classifyToolExits(settled)
|
||||||
|
|
||||||
|
if (tools.declined || streamInterrupted || tools.interrupted) {
|
||||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||||
}
|
}
|
||||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
if (tools.failure !== undefined) {
|
||||||
// implementation becomes a failed tool call the model can read, and the step still
|
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
|
||||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
|
||||||
// could not be persisted) also fails the assistant and then fails the drain.
|
|
||||||
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
|
||||||
const infraError =
|
|
||||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
|
||||||
if (settledFailure !== undefined) {
|
|
||||||
const failure = infraError ?? Cause.squash(settledFailure)
|
|
||||||
const error = toSessionError(failure)
|
|
||||||
yield* serialized(publisher.failUnsettledTools(error))
|
yield* serialized(publisher.failUnsettledTools(error))
|
||||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||||
|
|
@ -338,63 +394,16 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||||
if (userDeclined) return yield* Effect.interrupt
|
if (tools.declined) return yield* Effect.interrupt
|
||||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
|
||||||
return yield* Effect.failCause(settledFailure)
|
return yield* Effect.failCause(tools.failure)
|
||||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||||
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
|
|
||||||
const runStep = Effect.fnUntraced(function* (
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
promotable: SessionPending.Promotable,
|
|
||||||
step: number,
|
|
||||||
) {
|
|
||||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(events, sessionID))
|
|
||||||
/**
|
|
||||||
* Consumes one retry allowance: sleeps the scheduled backoff and reports what the next
|
|
||||||
* attempt should reuse, or publishes Step.Failed and fails once attempts are exhausted.
|
|
||||||
* The step loop performs the retry itself on the next iteration.
|
|
||||||
*/
|
|
||||||
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
|
|
||||||
retry(failure).pipe(
|
|
||||||
Effect.as(CallOutcome.Retry({ step: failure.step, assistantMessageID: failure.assistantMessageID })),
|
|
||||||
Pull.catchDone(() =>
|
|
||||||
events
|
|
||||||
.publish(SessionEvent.Step.Failed, {
|
|
||||||
sessionID,
|
|
||||||
assistantMessageID: failure.assistantMessageID,
|
|
||||||
error: failure.error,
|
|
||||||
})
|
|
||||||
.pipe(Effect.andThen(Effect.fail(failure.cause))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
let currentPromotable: SessionPending.Promotable | undefined = promotable
|
|
||||||
let currentStep = step
|
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
|
||||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
|
||||||
let recoverOverflow = true
|
|
||||||
while (true) {
|
|
||||||
const outcome = yield* callModel(
|
|
||||||
sessionID,
|
|
||||||
currentPromotable,
|
|
||||||
currentStep,
|
|
||||||
recoverOverflow,
|
|
||||||
assistantMessageID,
|
|
||||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
|
||||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
|
||||||
if (outcome._tag === "Retry") assistantMessageID = outcome.assistantMessageID
|
|
||||||
if (outcome._tag === "Restart" && outcome.recoveredOverflow) recoverOverflow = false
|
|
||||||
// Neither a retry nor a compaction restart re-promotes input.
|
|
||||||
currentPromotable = undefined
|
|
||||||
currentStep = outcome.step
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
|
@ -429,39 +438,36 @@ const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** Closes stale tool calls left active by an earlier interrupted drain. */
|
||||||
* Runs logical steps until no tool result or newly admitted steer requires another
|
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||||
* model call. Queued inputs remain pending until the current model work reaches idle.
|
sessionID: SessionSchema.ID,
|
||||||
*/
|
) {
|
||||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
|
for (const message of yield* store.context(sessionID)) {
|
||||||
// Fresh work may promote queued input; later steps absorb steers only.
|
if (message.type !== "assistant") continue
|
||||||
let promotable: SessionPending.Promotable = "input"
|
for (const tool of message.content) {
|
||||||
let step = 1
|
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||||
while (true) {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
const result = yield* runStep(sessionID, promotable, step)
|
sessionID,
|
||||||
yield* startTitleOnce(sessionID)
|
assistantMessageID: message.id,
|
||||||
yield* runPendingCompaction(sessionID)
|
callID: tool.id,
|
||||||
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||||
promotable = "steer"
|
executed: tool.executed === true,
|
||||||
step = result.step + 1
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** Fires title generation once per process after the first step makes a user message visible. */
|
||||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
if (titleStarted.has(sessionID)) return
|
||||||
*/
|
titleStarted.add(sessionID)
|
||||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
|
||||||
readonly sessionID: SessionSchema.ID
|
})
|
||||||
readonly force: boolean
|
|
||||||
}) {
|
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||||
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
|
const session = yield* store.get(sessionID)
|
||||||
yield* settleStaleToolCalls(input.sessionID)
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||||
yield* runPendingCompaction(input.sessionID)
|
return session
|
||||||
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
|
|
||||||
do {
|
|
||||||
yield* runSteps(input.sessionID)
|
|
||||||
} while (yield* SessionPending.has(db, input.sessionID, "input"))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ drain })
|
return Service.of({ drain })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue