|
|
@@ -1,430 +1,554 @@
|
|
|
-import { MessageV2 } from "./message-v2"
|
|
|
-import { Log } from "@/util/log"
|
|
|
-import { Session } from "."
|
|
|
+import { Cause, Effect, Exit, Layer, ServiceMap } from "effect"
|
|
|
+import * as Stream from "effect/Stream"
|
|
|
import { Agent } from "@/agent/agent"
|
|
|
-import { Snapshot } from "@/snapshot"
|
|
|
-import { SessionSummary } from "./summary"
|
|
|
import { Bus } from "@/bus"
|
|
|
+import { makeRuntime } from "@/effect/run-service"
|
|
|
+import { Config } from "@/config/config"
|
|
|
+import { Permission } from "@/permission"
|
|
|
+import { Plugin } from "@/plugin"
|
|
|
+import { Snapshot } from "@/snapshot"
|
|
|
+import { Log } from "@/util/log"
|
|
|
+import { Session } from "."
|
|
|
+import { LLM } from "./llm"
|
|
|
+import { MessageV2 } from "./message-v2"
|
|
|
+import { isOverflow } from "./overflow"
|
|
|
+import { PartID } from "./schema"
|
|
|
+import type { SessionID } from "./schema"
|
|
|
import { SessionRetry } from "./retry"
|
|
|
import { SessionStatus } from "./status"
|
|
|
-import { Plugin } from "@/plugin"
|
|
|
+import { SessionSummary } from "./summary"
|
|
|
import type { Provider } from "@/provider/provider"
|
|
|
-import { LLM } from "./llm"
|
|
|
-import { Config } from "@/config/config"
|
|
|
-import { SessionCompaction } from "./compaction"
|
|
|
-import { Permission } from "@/permission"
|
|
|
import { Question } from "@/question"
|
|
|
-import { PartID } from "./schema"
|
|
|
-import type { SessionID, MessageID } from "./schema"
|
|
|
|
|
|
export namespace SessionProcessor {
|
|
|
const DOOM_LOOP_THRESHOLD = 3
|
|
|
const log = Log.create({ service: "session.processor" })
|
|
|
|
|
|
- export type Info = Awaited<ReturnType<typeof create>>
|
|
|
- export type Result = Awaited<ReturnType<Info["process"]>>
|
|
|
+ export type Result = "compact" | "stop" | "continue"
|
|
|
+
|
|
|
+ export type Event = LLM.Event
|
|
|
+
|
|
|
+ export interface Handle {
|
|
|
+ readonly message: MessageV2.Assistant
|
|
|
+ readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
|
|
+ readonly abort: () => Effect.Effect<void>
|
|
|
+ readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
|
|
+ }
|
|
|
+
|
|
|
+ export interface Info {
|
|
|
+ readonly message: MessageV2.Assistant
|
|
|
+ readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
|
|
+ readonly process: (streamInput: LLM.StreamInput) => Promise<Result>
|
|
|
+ }
|
|
|
|
|
|
- export function create(input: {
|
|
|
+ type Input = {
|
|
|
assistantMessage: MessageV2.Assistant
|
|
|
sessionID: SessionID
|
|
|
model: Provider.Model
|
|
|
abort: AbortSignal
|
|
|
- }) {
|
|
|
- const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
|
|
- 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 {
|
|
|
- let currentText: MessageV2.TextPart | undefined
|
|
|
- let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
|
|
- const stream = await LLM.stream(streamInput)
|
|
|
-
|
|
|
- for await (const value of stream.fullStream) {
|
|
|
- input.abort.throwIfAborted()
|
|
|
- switch (value.type) {
|
|
|
- case "start":
|
|
|
- await SessionStatus.set(input.sessionID, { type: "busy" })
|
|
|
- break
|
|
|
-
|
|
|
- case "reasoning-start":
|
|
|
- if (value.id in reasoningMap) {
|
|
|
- continue
|
|
|
- }
|
|
|
- const reasoningPart = {
|
|
|
- id: PartID.ascending(),
|
|
|
- messageID: input.assistantMessage.id,
|
|
|
- sessionID: input.assistantMessage.sessionID,
|
|
|
- type: "reasoning" as const,
|
|
|
- text: "",
|
|
|
- time: {
|
|
|
- start: Date.now(),
|
|
|
- },
|
|
|
- metadata: value.providerMetadata,
|
|
|
- }
|
|
|
- reasoningMap[value.id] = reasoningPart
|
|
|
- await Session.updatePart(reasoningPart)
|
|
|
- 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
|
|
|
- await Session.updatePartDelta({
|
|
|
- sessionID: part.sessionID,
|
|
|
- messageID: part.messageID,
|
|
|
- partID: part.id,
|
|
|
- field: "text",
|
|
|
- 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 ?? PartID.ascending(),
|
|
|
- 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 Permission.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 instanceof Error ? value.error.message : String(value.error),
|
|
|
- time: {
|
|
|
- start: match.state.time.start,
|
|
|
- end: Date.now(),
|
|
|
- },
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- if (
|
|
|
- value.error instanceof Permission.RejectedError ||
|
|
|
- value.error instanceof Question.RejectedError
|
|
|
- ) {
|
|
|
- blocked = shouldBreak
|
|
|
- }
|
|
|
- delete toolcalls[value.toolCallId]
|
|
|
- }
|
|
|
- break
|
|
|
- }
|
|
|
- case "error":
|
|
|
- throw value.error
|
|
|
+ export interface Interface {
|
|
|
+ readonly create: (input: Input) => Effect.Effect<Handle>
|
|
|
+ }
|
|
|
|
|
|
- case "start-step":
|
|
|
- snapshot = await Snapshot.track()
|
|
|
- await Session.updatePart({
|
|
|
- id: PartID.ascending(),
|
|
|
- messageID: input.assistantMessage.id,
|
|
|
- sessionID: input.sessionID,
|
|
|
- snapshot,
|
|
|
- type: "step-start",
|
|
|
- })
|
|
|
- break
|
|
|
+ interface ProcessorContext extends Input {
|
|
|
+ toolcalls: Record<string, MessageV2.ToolPart>
|
|
|
+ shouldBreak: boolean
|
|
|
+ snapshot: string | undefined
|
|
|
+ blocked: boolean
|
|
|
+ needsCompaction: boolean
|
|
|
+ currentText: MessageV2.TextPart | undefined
|
|
|
+ reasoningMap: Record<string, MessageV2.ReasoningPart>
|
|
|
+ }
|
|
|
|
|
|
- case "finish-step":
|
|
|
- const usage = Session.getUsage({
|
|
|
- model: input.model,
|
|
|
- usage: value.usage,
|
|
|
- metadata: value.providerMetadata,
|
|
|
- })
|
|
|
- input.assistantMessage.finish = value.finishReason
|
|
|
- input.assistantMessage.cost += usage.cost
|
|
|
- input.assistantMessage.tokens = usage.tokens
|
|
|
- await Session.updatePart({
|
|
|
- id: PartID.ascending(),
|
|
|
- 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)
|
|
|
- if (snapshot) {
|
|
|
- const patch = await Snapshot.patch(snapshot)
|
|
|
- if (patch.files.length) {
|
|
|
- await Session.updatePart({
|
|
|
- id: PartID.ascending(),
|
|
|
- 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 (
|
|
|
- !input.assistantMessage.summary &&
|
|
|
- (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model }))
|
|
|
- ) {
|
|
|
- needsCompaction = true
|
|
|
- }
|
|
|
- break
|
|
|
-
|
|
|
- case "text-start":
|
|
|
- currentText = {
|
|
|
- id: PartID.ascending(),
|
|
|
- messageID: input.assistantMessage.id,
|
|
|
- sessionID: input.assistantMessage.sessionID,
|
|
|
- type: "text",
|
|
|
- text: "",
|
|
|
- time: {
|
|
|
- start: Date.now(),
|
|
|
- },
|
|
|
- metadata: value.providerMetadata,
|
|
|
- }
|
|
|
- await Session.updatePart(currentText)
|
|
|
- break
|
|
|
-
|
|
|
- case "text-delta":
|
|
|
- if (currentText) {
|
|
|
- currentText.text += value.text
|
|
|
- if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
|
|
- await Session.updatePartDelta({
|
|
|
- sessionID: currentText.sessionID,
|
|
|
- messageID: currentText.messageID,
|
|
|
- partID: currentText.id,
|
|
|
- field: "text",
|
|
|
- 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
|
|
|
+ type StreamEvent = Event
|
|
|
+
|
|
|
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
|
|
+
|
|
|
+ export const layer: Layer.Layer<
|
|
|
+ Service,
|
|
|
+ never,
|
|
|
+ | Session.Service
|
|
|
+ | Config.Service
|
|
|
+ | Bus.Service
|
|
|
+ | Snapshot.Service
|
|
|
+ | Agent.Service
|
|
|
+ | LLM.Service
|
|
|
+ | Permission.Service
|
|
|
+ | Plugin.Service
|
|
|
+ | SessionStatus.Service
|
|
|
+ > = Layer.effect(
|
|
|
+ Service,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const session = yield* Session.Service
|
|
|
+ const config = yield* Config.Service
|
|
|
+ const bus = yield* Bus.Service
|
|
|
+ const snapshot = yield* Snapshot.Service
|
|
|
+ const agents = yield* Agent.Service
|
|
|
+ const llm = yield* LLM.Service
|
|
|
+ const permission = yield* Permission.Service
|
|
|
+ const plugin = yield* Plugin.Service
|
|
|
+ const status = yield* SessionStatus.Service
|
|
|
+
|
|
|
+ const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
|
|
+ const ctx: ProcessorContext = {
|
|
|
+ assistantMessage: input.assistantMessage,
|
|
|
+ sessionID: input.sessionID,
|
|
|
+ model: input.model,
|
|
|
+ abort: input.abort,
|
|
|
+ toolcalls: {},
|
|
|
+ shouldBreak: false,
|
|
|
+ snapshot: undefined,
|
|
|
+ blocked: false,
|
|
|
+ needsCompaction: false,
|
|
|
+ currentText: undefined,
|
|
|
+ reasoningMap: {},
|
|
|
+ }
|
|
|
+
|
|
|
+ const parse = (e: unknown) =>
|
|
|
+ MessageV2.fromError(e, {
|
|
|
+ providerID: input.model.providerID,
|
|
|
+ aborted: input.abort.aborted,
|
|
|
+ })
|
|
|
+
|
|
|
+ const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
|
|
+ switch (value.type) {
|
|
|
+ case "start":
|
|
|
+ yield* status.set(ctx.sessionID, { type: "busy" })
|
|
|
+ return
|
|
|
+
|
|
|
+ case "reasoning-start":
|
|
|
+ if (value.id in ctx.reasoningMap) return
|
|
|
+ ctx.reasoningMap[value.id] = {
|
|
|
+ id: PartID.ascending(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ type: "reasoning",
|
|
|
+ text: "",
|
|
|
+ time: { start: Date.now() },
|
|
|
+ metadata: value.providerMetadata,
|
|
|
+ }
|
|
|
+ yield* session.updatePart(ctx.reasoningMap[value.id])
|
|
|
+ return
|
|
|
+
|
|
|
+ case "reasoning-delta":
|
|
|
+ if (!(value.id in ctx.reasoningMap)) return
|
|
|
+ ctx.reasoningMap[value.id].text += value.text
|
|
|
+ if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
|
|
+ yield* session.updatePartDelta({
|
|
|
+ sessionID: ctx.reasoningMap[value.id].sessionID,
|
|
|
+ messageID: ctx.reasoningMap[value.id].messageID,
|
|
|
+ partID: ctx.reasoningMap[value.id].id,
|
|
|
+ field: "text",
|
|
|
+ delta: value.text,
|
|
|
+ })
|
|
|
+ return
|
|
|
+
|
|
|
+ case "reasoning-end":
|
|
|
+ if (!(value.id in ctx.reasoningMap)) return
|
|
|
+ ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
|
|
|
+ ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
|
|
+ if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
|
|
+ yield* session.updatePart(ctx.reasoningMap[value.id])
|
|
|
+ delete ctx.reasoningMap[value.id]
|
|
|
+ return
|
|
|
+
|
|
|
+ case "tool-input-start":
|
|
|
+ ctx.toolcalls[value.id] = (yield* session.updatePart({
|
|
|
+ id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ type: "tool",
|
|
|
+ tool: value.toolName,
|
|
|
+ callID: value.id,
|
|
|
+ state: { status: "pending", input: {}, raw: "" },
|
|
|
+ })) as MessageV2.ToolPart
|
|
|
+ return
|
|
|
+
|
|
|
+ case "tool-input-delta":
|
|
|
+ return
|
|
|
+
|
|
|
+ case "tool-input-end":
|
|
|
+ return
|
|
|
+
|
|
|
+ case "tool-call": {
|
|
|
+ const match = ctx.toolcalls[value.toolCallId]
|
|
|
+ if (!match) return
|
|
|
+ ctx.toolcalls[value.toolCallId] = (yield* session.updatePart({
|
|
|
+ ...match,
|
|
|
+ tool: value.toolName,
|
|
|
+ state: { status: "running", input: value.input, time: { start: Date.now() } },
|
|
|
+ metadata: value.providerMetadata,
|
|
|
+ })) as MessageV2.ToolPart
|
|
|
+
|
|
|
+ const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
|
|
+ const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
|
|
+
|
|
|
+ if (
|
|
|
+ recentParts.length !== DOOM_LOOP_THRESHOLD ||
|
|
|
+ !recentParts.every(
|
|
|
+ (part) =>
|
|
|
+ part.type === "tool" &&
|
|
|
+ part.tool === value.toolName &&
|
|
|
+ part.state.status !== "pending" &&
|
|
|
+ JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ return
|
|
|
}
|
|
|
- if (needsCompaction) break
|
|
|
+
|
|
|
+ const agent = yield* agents.get(ctx.assistantMessage.agent)
|
|
|
+ yield* permission.ask({
|
|
|
+ permission: "doom_loop",
|
|
|
+ patterns: [value.toolName],
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ metadata: { tool: value.toolName, input: value.input },
|
|
|
+ always: [value.toolName],
|
|
|
+ ruleset: agent.permission,
|
|
|
+ })
|
|
|
+ return
|
|
|
}
|
|
|
- } catch (e: any) {
|
|
|
- log.error("process", {
|
|
|
- error: e,
|
|
|
- stack: JSON.stringify(e.stack),
|
|
|
- })
|
|
|
- const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
|
|
|
- if (MessageV2.ContextOverflowError.isInstance(error)) {
|
|
|
- needsCompaction = true
|
|
|
- Bus.publish(Session.Event.Error, {
|
|
|
- sessionID: input.sessionID,
|
|
|
- error,
|
|
|
+
|
|
|
+ case "tool-result": {
|
|
|
+ const match = ctx.toolcalls[value.toolCallId]
|
|
|
+ if (!match || match.state.status !== "running") return
|
|
|
+ yield* 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,
|
|
|
+ },
|
|
|
})
|
|
|
- } else {
|
|
|
- const retry = SessionRetry.retryable(error)
|
|
|
- if (retry !== undefined) {
|
|
|
- attempt++
|
|
|
- const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
|
|
- await SessionStatus.set(input.sessionID, {
|
|
|
- type: "retry",
|
|
|
- attempt,
|
|
|
- message: retry,
|
|
|
- next: Date.now() + delay,
|
|
|
- })
|
|
|
- await SessionRetry.sleep(delay, input.abort).catch(() => {})
|
|
|
- continue
|
|
|
+ delete ctx.toolcalls[value.toolCallId]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ case "tool-error": {
|
|
|
+ const match = ctx.toolcalls[value.toolCallId]
|
|
|
+ if (!match || match.state.status !== "running") return
|
|
|
+ yield* session.updatePart({
|
|
|
+ ...match,
|
|
|
+ state: {
|
|
|
+ status: "error",
|
|
|
+ input: value.input ?? match.state.input,
|
|
|
+ error: value.error instanceof Error ? value.error.message : String(value.error),
|
|
|
+ time: { start: match.state.time.start, end: Date.now() },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
|
|
|
+ ctx.blocked = ctx.shouldBreak
|
|
|
}
|
|
|
- input.assistantMessage.error = error
|
|
|
- Bus.publish(Session.Event.Error, {
|
|
|
- sessionID: input.assistantMessage.sessionID,
|
|
|
- error: input.assistantMessage.error,
|
|
|
+ delete ctx.toolcalls[value.toolCallId]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ case "error":
|
|
|
+ throw value.error
|
|
|
+
|
|
|
+ case "start-step":
|
|
|
+ ctx.snapshot = yield* snapshot.track()
|
|
|
+ yield* session.updatePart({
|
|
|
+ id: PartID.ascending(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.sessionID,
|
|
|
+ snapshot: ctx.snapshot,
|
|
|
+ type: "step-start",
|
|
|
+ })
|
|
|
+ return
|
|
|
+
|
|
|
+ case "finish-step": {
|
|
|
+ const usage = Session.getUsage({
|
|
|
+ model: ctx.model,
|
|
|
+ usage: value.usage,
|
|
|
+ metadata: value.providerMetadata,
|
|
|
})
|
|
|
- await SessionStatus.set(input.sessionID, { type: "idle" })
|
|
|
+ ctx.assistantMessage.finish = value.finishReason
|
|
|
+ ctx.assistantMessage.cost += usage.cost
|
|
|
+ ctx.assistantMessage.tokens = usage.tokens
|
|
|
+ yield* session.updatePart({
|
|
|
+ id: PartID.ascending(),
|
|
|
+ reason: value.finishReason,
|
|
|
+ snapshot: yield* snapshot.track(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ type: "step-finish",
|
|
|
+ tokens: usage.tokens,
|
|
|
+ cost: usage.cost,
|
|
|
+ })
|
|
|
+ yield* session.updateMessage(ctx.assistantMessage)
|
|
|
+ if (ctx.snapshot) {
|
|
|
+ const patch = yield* snapshot.patch(ctx.snapshot)
|
|
|
+ if (patch.files.length) {
|
|
|
+ yield* session.updatePart({
|
|
|
+ id: PartID.ascending(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.sessionID,
|
|
|
+ type: "patch",
|
|
|
+ hash: patch.hash,
|
|
|
+ files: patch.files,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ ctx.snapshot = undefined
|
|
|
+ }
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ SessionSummary.summarize({
|
|
|
+ sessionID: ctx.sessionID,
|
|
|
+ messageID: ctx.assistantMessage.parentID,
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.ignoreCause({ log: true, message: "session summary failed" }), Effect.forkDetach)
|
|
|
+ if (
|
|
|
+ !ctx.assistantMessage.summary &&
|
|
|
+ isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
|
|
+ ) {
|
|
|
+ ctx.needsCompaction = true
|
|
|
+ }
|
|
|
+ return
|
|
|
}
|
|
|
+
|
|
|
+ case "text-start":
|
|
|
+ ctx.currentText = {
|
|
|
+ id: PartID.ascending(),
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ type: "text",
|
|
|
+ text: "",
|
|
|
+ time: { start: Date.now() },
|
|
|
+ metadata: value.providerMetadata,
|
|
|
+ }
|
|
|
+ yield* session.updatePart(ctx.currentText)
|
|
|
+ return
|
|
|
+
|
|
|
+ case "text-delta":
|
|
|
+ if (!ctx.currentText) return
|
|
|
+ ctx.currentText.text += value.text
|
|
|
+ if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
|
|
+ yield* session.updatePartDelta({
|
|
|
+ sessionID: ctx.currentText.sessionID,
|
|
|
+ messageID: ctx.currentText.messageID,
|
|
|
+ partID: ctx.currentText.id,
|
|
|
+ field: "text",
|
|
|
+ delta: value.text,
|
|
|
+ })
|
|
|
+ return
|
|
|
+
|
|
|
+ case "text-end":
|
|
|
+ if (!ctx.currentText) return
|
|
|
+ ctx.currentText.text = ctx.currentText.text.trimEnd()
|
|
|
+ ctx.currentText.text = (yield* plugin.trigger(
|
|
|
+ "experimental.text.complete",
|
|
|
+ {
|
|
|
+ sessionID: ctx.sessionID,
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ partID: ctx.currentText.id,
|
|
|
+ },
|
|
|
+ { text: ctx.currentText.text },
|
|
|
+ )).text
|
|
|
+ ctx.currentText.time = { start: Date.now(), end: Date.now() }
|
|
|
+ if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
|
|
+ yield* session.updatePart(ctx.currentText)
|
|
|
+ ctx.currentText = undefined
|
|
|
+ return
|
|
|
+
|
|
|
+ case "finish":
|
|
|
+ return
|
|
|
+
|
|
|
+ default:
|
|
|
+ log.info("unhandled", { ...value })
|
|
|
+ return
|
|
|
}
|
|
|
- if (snapshot) {
|
|
|
- const patch = await Snapshot.patch(snapshot)
|
|
|
+ })
|
|
|
+
|
|
|
+ const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
|
|
|
+ if (ctx.snapshot) {
|
|
|
+ const patch = yield* snapshot.patch(ctx.snapshot)
|
|
|
if (patch.files.length) {
|
|
|
- await Session.updatePart({
|
|
|
+ yield* session.updatePart({
|
|
|
id: PartID.ascending(),
|
|
|
- messageID: input.assistantMessage.id,
|
|
|
- sessionID: input.sessionID,
|
|
|
+ messageID: ctx.assistantMessage.id,
|
|
|
+ sessionID: ctx.sessionID,
|
|
|
type: "patch",
|
|
|
hash: patch.hash,
|
|
|
files: patch.files,
|
|
|
})
|
|
|
}
|
|
|
- snapshot = undefined
|
|
|
+ ctx.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(),
|
|
|
- },
|
|
|
- },
|
|
|
- })
|
|
|
- }
|
|
|
+
|
|
|
+ if (ctx.currentText) {
|
|
|
+ const end = Date.now()
|
|
|
+ ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
|
|
+ yield* session.updatePart(ctx.currentText)
|
|
|
+ ctx.currentText = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const part of Object.values(ctx.reasoningMap)) {
|
|
|
+ const end = Date.now()
|
|
|
+ yield* session.updatePart({
|
|
|
+ ...part,
|
|
|
+ time: { start: part.time.start ?? end, end },
|
|
|
+ })
|
|
|
}
|
|
|
- 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"
|
|
|
+ ctx.reasoningMap = {}
|
|
|
+
|
|
|
+ const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
|
|
+ for (const part of parts) {
|
|
|
+ if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
|
|
|
+ yield* session.updatePart({
|
|
|
+ ...part,
|
|
|
+ state: {
|
|
|
+ ...part.state,
|
|
|
+ status: "error",
|
|
|
+ error: "Tool execution aborted",
|
|
|
+ time: { start: Date.now(), end: Date.now() },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ ctx.assistantMessage.time.completed = Date.now()
|
|
|
+ yield* session.updateMessage(ctx.assistantMessage)
|
|
|
+ })
|
|
|
+
|
|
|
+ const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
|
|
+ log.error("process", { error: e, stack: JSON.stringify((e as any)?.stack) })
|
|
|
+ const error = parse(e)
|
|
|
+ if (MessageV2.ContextOverflowError.isInstance(error)) {
|
|
|
+ ctx.needsCompaction = true
|
|
|
+ yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ctx.assistantMessage.error = error
|
|
|
+ yield* bus.publish(Session.Event.Error, {
|
|
|
+ sessionID: ctx.assistantMessage.sessionID,
|
|
|
+ error: ctx.assistantMessage.error,
|
|
|
+ })
|
|
|
+ yield* status.set(ctx.sessionID, { type: "idle" })
|
|
|
+ })
|
|
|
+
|
|
|
+ const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
|
|
+ log.info("process")
|
|
|
+ ctx.needsCompaction = false
|
|
|
+ ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
|
|
+
|
|
|
+ yield* Effect.gen(function* () {
|
|
|
+ ctx.currentText = undefined
|
|
|
+ ctx.reasoningMap = {}
|
|
|
+ const stream = llm.stream(streamInput)
|
|
|
+
|
|
|
+ yield* stream.pipe(
|
|
|
+ Stream.tap((event) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ input.abort.throwIfAborted()
|
|
|
+ yield* handleEvent(event)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ Stream.takeUntil(() => ctx.needsCompaction),
|
|
|
+ Stream.runDrain,
|
|
|
+ )
|
|
|
+ }).pipe(
|
|
|
+ Effect.catchCauseIf(
|
|
|
+ (cause) => !Cause.hasInterruptsOnly(cause),
|
|
|
+ (cause) => Effect.fail(Cause.squash(cause)),
|
|
|
+ ),
|
|
|
+ Effect.retry(
|
|
|
+ SessionRetry.policy({
|
|
|
+ parse,
|
|
|
+ set: (info) =>
|
|
|
+ status.set(ctx.sessionID, {
|
|
|
+ type: "retry",
|
|
|
+ attempt: info.attempt,
|
|
|
+ message: info.message,
|
|
|
+ next: info.next,
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ Effect.catchCause((cause) =>
|
|
|
+ Cause.hasInterruptsOnly(cause)
|
|
|
+ ? halt(new DOMException("Aborted", "AbortError"))
|
|
|
+ : halt(Cause.squash(cause)),
|
|
|
+ ),
|
|
|
+ Effect.ensuring(cleanup()),
|
|
|
+ )
|
|
|
+
|
|
|
+ if (input.abort.aborted && !ctx.assistantMessage.error) {
|
|
|
+ yield* abort()
|
|
|
+ }
|
|
|
+ if (ctx.needsCompaction) return "compact"
|
|
|
+ if (ctx.blocked || ctx.assistantMessage.error || input.abort.aborted) return "stop"
|
|
|
return "continue"
|
|
|
+ })
|
|
|
+
|
|
|
+ const abort = Effect.fn("SessionProcessor.abort")(() =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ if (!ctx.assistantMessage.error) {
|
|
|
+ yield* halt(new DOMException("Aborted", "AbortError"))
|
|
|
+ }
|
|
|
+ if (!ctx.assistantMessage.time.completed) {
|
|
|
+ yield* cleanup()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ yield* session.updateMessage(ctx.assistantMessage)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ return {
|
|
|
+ get message() {
|
|
|
+ return ctx.assistantMessage
|
|
|
+ },
|
|
|
+ partFromToolCall(toolCallID: string) {
|
|
|
+ return ctx.toolcalls[toolCallID]
|
|
|
+ },
|
|
|
+ abort,
|
|
|
+ process,
|
|
|
+ } satisfies Handle
|
|
|
+ })
|
|
|
+
|
|
|
+ return Service.of({ create })
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ export const defaultLayer = Layer.unwrap(
|
|
|
+ Effect.sync(() =>
|
|
|
+ layer.pipe(
|
|
|
+ Layer.provide(Session.defaultLayer),
|
|
|
+ Layer.provide(Snapshot.defaultLayer),
|
|
|
+ Layer.provide(Agent.defaultLayer),
|
|
|
+ Layer.provide(LLM.defaultLayer),
|
|
|
+ Layer.provide(Permission.layer),
|
|
|
+ Layer.provide(Plugin.defaultLayer),
|
|
|
+ Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
|
|
|
+ Layer.provide(Bus.layer),
|
|
|
+ Layer.provide(Config.defaultLayer),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
|
+
|
|
|
+ export async function create(input: Input): Promise<Info> {
|
|
|
+ const hit = await runPromise((svc) => svc.create(input))
|
|
|
+ return {
|
|
|
+ get message() {
|
|
|
+ return hit.message
|
|
|
+ },
|
|
|
+ partFromToolCall(toolCallID: string) {
|
|
|
+ return hit.partFromToolCall(toolCallID)
|
|
|
+ },
|
|
|
+ async process(streamInput: LLM.StreamInput) {
|
|
|
+ const exit = await Effect.runPromiseExit(hit.process(streamInput), { signal: input.abort })
|
|
|
+ if (Exit.isFailure(exit)) {
|
|
|
+ if (Cause.hasInterrupts(exit.cause) && input.abort.aborted) {
|
|
|
+ await Effect.runPromise(hit.abort())
|
|
|
+ return "stop"
|
|
|
+ }
|
|
|
+ throw Cause.squash(exit.cause)
|
|
|
}
|
|
|
+ return exit.value
|
|
|
},
|
|
|
}
|
|
|
- return result
|
|
|
}
|
|
|
}
|