import { MessageV2 } from "./message-v2" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." import { Agent } from "@/agent/agent" import { Snapshot } from "@/snapshot" import { SessionSummary } from "./summary" import { Bus } from "@/bus" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" import { OpenScience, InsufficientCreditsError } from "@/openscience" import { requiresWalletBalance, shouldReportUsage, resolveCredentialSource, llmBillingMode } from "./billing-gate" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) export type Info = Awaited> export type Result = Awaited> export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string model: Provider.Model abort: AbortSignal }) { const toolcalls: Record = {} let snapshot: string | undefined let blocked = false let attempt = 0 let needsCompaction = false const result = { get message() { return input.assistantMessage }, partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, async process(streamInput: LLM.StreamInput) { log.info("process") needsCompaction = false const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { // Check for dashboard-side BYOK/managed changes before each user message. await OpenScience.refreshIfStale() // Classify the credential backing this call. The wallet pre-flight // fires ONLY for managed-proxy credentials (a thk_* token / synced // secret). BYOK keys and first-party OAuth subscriptions (Claude // Pro/Max, Sign in with ChatGPT, Copilot) run on the user's own // account — an empty wallet must never block or gate them. const credentialSource = await resolveCredentialSource(input.model.providerID, input.model.id) // Managed spend is ON but this call resolved to a non-managed credential — // the Atlas wallet isn't wired to this provider. Fail clearly instead of // silently running on a BYOK/OAuth credential the user didn't intend. if ((await llmBillingMode()) === "managed" && credentialSource !== "managed") { throw new Error( `Managed LLM spend is on, but ${input.model.providerID} isn't connected to your Atlas wallet. Sign in with \`openscience login\` and run \`openscience connect sync\`, or switch LLM spend to BYOK in Settings → Spend.`, ) } // Pre-flight wallet check for managed-proxy calls only. Block ONLY on a // VERIFIED empty or overdrafted wallet. If the balance can't be verified // (null: no/expired Atlas session or a transient error), do NOT hard-block — // the managed proxy is the billing authority and returns 402 if actually // out of credits. Hard-blocking here strands a user whose session lapsed. if (requiresWalletBalance(credentialSource)) { const balance = await OpenScience.getBalance() if (balance !== null && balance <= 0) { // Drop the 30s cache so a top-up is visible on the next // attempt instead of blocking until the TTL expires. OpenScience.invalidateBalance() throw new Error( "Your Atlas wallet is empty. Top up at app.syntheticsciences.ai/cli, or switch LLM spend to BYOK in Settings → Spend — BYOK uses your own key and is never billed.", ) } } let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted() switch (value.type) { case "start": SessionStatus.set(input.sessionID, { type: "busy" }) break case "reasoning-start": if (value.id in reasoningMap) { continue } reasoningMap[value.id] = { id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "reasoning", text: "", time: { start: Date.now(), }, metadata: value.providerMetadata, } break case "reasoning-delta": if (value.id in reasoningMap) { const part = reasoningMap[value.id] part.text += value.text if (value.providerMetadata) part.metadata = value.providerMetadata if (part.text) await Session.updatePart({ part, delta: value.text }) } break case "reasoning-end": if (value.id in reasoningMap) { const part = reasoningMap[value.id] part.text = part.text.trimEnd() part.time = { ...part.time, end: Date.now(), } if (value.providerMetadata) part.metadata = value.providerMetadata await Session.updatePart(part) delete reasoningMap[value.id] } break case "tool-input-start": const part = await Session.updatePart({ id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "tool", tool: value.toolName, callID: value.id, state: { status: "pending", input: {}, raw: "", }, }) toolcalls[value.id] = part as MessageV2.ToolPart break case "tool-input-delta": break case "tool-input-end": break case "tool-call": { const match = toolcalls[value.toolCallId] if (match) { const part = await Session.updatePart({ ...match, tool: value.toolName, state: { status: "running", input: value.input, time: { start: Date.now(), }, }, metadata: value.providerMetadata, }) toolcalls[value.toolCallId] = part as MessageV2.ToolPart const parts = await MessageV2.parts(input.assistantMessage.id) const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) if ( lastThree.length === DOOM_LOOP_THRESHOLD && lastThree.every( (p) => p.type === "tool" && p.tool === value.toolName && p.state.status !== "pending" && JSON.stringify(p.state.input) === JSON.stringify(value.input), ) ) { const agent = await Agent.get(input.assistantMessage.agent) await PermissionNext.ask({ permission: "doom_loop", patterns: [value.toolName], sessionID: input.assistantMessage.sessionID, metadata: { tool: value.toolName, input: value.input, }, always: [value.toolName], ruleset: agent.permission, }) } } break } case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { await Session.updatePart({ ...match, state: { status: "completed", input: value.input ?? match.state.input, output: value.output.output, metadata: value.output.metadata, title: value.output.title, time: { start: match.state.time.start, end: Date.now(), }, attachments: value.output.attachments, }, }) delete toolcalls[value.toolCallId] } break } case "tool-error": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { await Session.updatePart({ ...match, state: { status: "error", input: value.input ?? match.state.input, error: (value.error as any).toString(), time: { start: match.state.time.start, end: Date.now(), }, }, }) if ( value.error instanceof PermissionNext.RejectedError || value.error instanceof Question.RejectedError ) { blocked = shouldBreak } delete toolcalls[value.toolCallId] } break } case "error": throw value.error case "start-step": snapshot = await Snapshot.track() await Session.updatePart({ id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.sessionID, snapshot, type: "step-start", }) break case "finish-step": const usage = Session.getUsage({ model: input.model, usage: value.usage, metadata: value.providerMetadata, }) const stepPartID = Identifier.ascending("part") input.assistantMessage.finish = value.finishReason input.assistantMessage.cost += usage.cost input.assistantMessage.tokens = usage.tokens await Session.updatePart({ id: stepPartID, reason: value.finishReason, snapshot: await Snapshot.track(), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "step-finish", tokens: usage.tokens, cost: usage.cost, }) await Session.updateMessage(input.assistantMessage) // Report usage ONLY for managed-proxy credentials. BYOK keys // and first-party OAuth subscriptions are billed to the user's // own account, not the openscience CLI wallet, so they are never // reported (regardless of the model's nominal models.dev price). const usageResult = !shouldReportUsage(credentialSource) ? null : await OpenScience.reportUsage({ service: "llm", event_type: "chat", model: input.model.id, tokens_used: usage.tokens.input + usage.tokens.output + usage.tokens.reasoning, metadata: { provider: input.model.providerID, input_tokens: usage.tokens.input, output_tokens: usage.tokens.output, reasoning_tokens: usage.tokens.reasoning, cache_read: usage.tokens.cache.read, cache_write: usage.tokens.cache.write, cost_usd: usage.cost, session_id: input.sessionID, message_id: input.assistantMessage.id, idempotency_key: stepPartID, }, }) if (usageResult && "modelBlocked" in usageResult) { log.warn("model blocked by server — halting session", { model: input.model.id }) // Hard stop. The user is out of credits (managed // mode) or has no active thesis subscription. The // current step's response is already in their // context; we just don't kick off the next loop. throw new InsufficientCreditsError() } if (snapshot) { const patch = await Snapshot.patch(snapshot) if (patch.files.length) { await Session.updatePart({ id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.sessionID, type: "patch", hash: patch.hash, files: patch.files, }) } snapshot = undefined } SessionSummary.summarize({ sessionID: input.sessionID, messageID: input.assistantMessage.parentID, }) if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) { needsCompaction = true } break case "text-start": currentText = { id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "text", text: "", time: { start: Date.now(), }, metadata: value.providerMetadata, } break case "text-delta": if (currentText) { currentText.text += value.text if (value.providerMetadata) currentText.metadata = value.providerMetadata if (currentText.text) await Session.updatePart({ part: currentText, delta: value.text, }) } break case "text-end": if (currentText) { currentText.text = currentText.text.trimEnd() const textOutput = await Plugin.trigger( "experimental.text.complete", { sessionID: input.sessionID, messageID: input.assistantMessage.id, partID: currentText.id, }, { text: currentText.text }, ) currentText.text = textOutput.text currentText.time = { start: Date.now(), end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata await Session.updatePart(currentText) } currentText = undefined break case "finish": break default: log.info("unhandled", { ...value, }) continue } if (needsCompaction) break } } catch (e: any) { log.error("process", { error: e, stack: JSON.stringify(e.stack), }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) const retry = SessionRetry.retryable(error) if (retry !== undefined) { attempt++ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) SessionStatus.set(input.sessionID, { type: "retry", attempt, message: retry, next: Date.now() + delay, }) await SessionRetry.sleep(delay, input.abort).catch(() => {}) continue } input.assistantMessage.error = error Bus.publish(Session.Event.Error, { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, }) } if (snapshot) { const patch = await Snapshot.patch(snapshot) if (patch.files.length) { await Session.updatePart({ id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.sessionID, type: "patch", hash: patch.hash, files: patch.files, }) } snapshot = undefined } const p = await MessageV2.parts(input.assistantMessage.id) for (const part of p) { if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { await Session.updatePart({ ...part, state: { ...part.state, status: "error", error: "Tool execution aborted", time: { start: Date.now(), end: Date.now(), }, }, }) } } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) if (needsCompaction) return "compact" if (blocked) return "stop" if (input.assistantMessage.error) return "stop" return "continue" } }, } return result } }