processor.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. import { PermissionV1 } from "@opencode-ai/core/v1/permission"
  2. import { Image } from "@/image/image"
  3. import { SessionV1 } from "@opencode-ai/core/v1/session"
  4. import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
  5. import * as Stream from "effect/Stream"
  6. import { Agent } from "@/agent/agent"
  7. import { Config } from "@/config/config"
  8. import { Permission } from "@/permission"
  9. import { Plugin } from "@/plugin"
  10. import { Snapshot } from "@/snapshot"
  11. import { Session } from "./session"
  12. import { LLM } from "./llm"
  13. import { MessageV2 } from "./message-v2"
  14. import { isOverflow } from "./overflow"
  15. import { PartID } from "./schema"
  16. import type { SessionID } from "./schema"
  17. import { SessionRetry } from "./retry"
  18. import { SessionStatus } from "./status"
  19. import { SessionSummary } from "./summary"
  20. import type { Provider } from "@/provider/provider"
  21. import { Question } from "@/question"
  22. import { errorMessage } from "@/util/error"
  23. import { Log } from "@opencode-ai/core/util/log"
  24. import { isRecord } from "@/util/record"
  25. import { EventV2Bridge } from "@/event-v2-bridge"
  26. import { Database } from "@opencode-ai/core/database/database"
  27. import { SessionEvent } from "@opencode-ai/core/session/event"
  28. import { ModelV2 } from "@opencode-ai/core/model"
  29. import { ProviderV2 } from "@opencode-ai/core/provider"
  30. import * as DateTime from "effect/DateTime"
  31. import { RuntimeFlags } from "@/effect/runtime-flags"
  32. import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
  33. import { ToolOutput } from "@opencode-ai/core/tool-output"
  34. import type { EventV2 } from "@opencode-ai/core/event"
  35. const DOOM_LOOP_THRESHOLD = 3
  36. const log = Log.create({ service: "session.processor" })
  37. export type Result = "compact" | "stop" | "continue"
  38. export interface Handle {
  39. readonly message: SessionV1.Assistant
  40. readonly updateToolCall: (
  41. toolCallID: string,
  42. update: (part: SessionV1.ToolPart) => SessionV1.ToolPart,
  43. ) => Effect.Effect<SessionV1.ToolPart | undefined>
  44. readonly completeToolCall: (
  45. toolCallID: string,
  46. output: {
  47. title: string
  48. metadata: Record<string, any>
  49. output: string
  50. attachments?: SessionV1.FilePart[]
  51. },
  52. ) => Effect.Effect<void>
  53. readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
  54. }
  55. type Input = {
  56. assistantMessage: SessionV1.Assistant
  57. sessionID: SessionID
  58. model: Provider.Model
  59. }
  60. export interface Interface {
  61. readonly create: (input: Input) => Effect.Effect<Handle>
  62. }
  63. type ToolCall = {
  64. assistantMessageID?: EventV2.ID
  65. partID: SessionV1.ToolPart["id"]
  66. messageID: SessionV1.ToolPart["messageID"]
  67. sessionID: SessionV1.ToolPart["sessionID"]
  68. done: Deferred.Deferred<void>
  69. inputEnded: boolean
  70. raw: string
  71. }
  72. interface ProcessorContext extends Input {
  73. toolcalls: Record<string, ToolCall>
  74. shouldBreak: boolean
  75. snapshot: string | undefined
  76. blocked: boolean
  77. needsCompaction: boolean
  78. currentText: SessionV1.TextPart | undefined
  79. currentTextID: string | undefined
  80. reasoningMap: Record<string, SessionV1.ReasoningPart>
  81. v2AssistantMessageID: EventV2.ID | undefined
  82. }
  83. type StreamEvent = LLMEvent
  84. export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
  85. export const layer = Layer.effect(
  86. Service,
  87. Effect.gen(function* () {
  88. const session = yield* Session.Service
  89. const config = yield* Config.Service
  90. const snapshot = yield* Snapshot.Service
  91. const agents = yield* Agent.Service
  92. const llm = yield* LLM.Service
  93. const permission = yield* Permission.Service
  94. const plugin = yield* Plugin.Service
  95. const summary = yield* SessionSummary.Service
  96. const scope = yield* Scope.Scope
  97. const status = yield* SessionStatus.Service
  98. const image = yield* Image.Service
  99. const events = yield* EventV2Bridge.Service
  100. const flags = yield* RuntimeFlags.Service
  101. const database = yield* Database.Service
  102. const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
  103. // Pre-capture snapshot before the LLM stream starts. The AI SDK
  104. // may execute tools internally before emitting start-step events,
  105. // so capturing inside the event handler can be too late.
  106. const initialSnapshot = yield* snapshot.track()
  107. const ctx: ProcessorContext = {
  108. assistantMessage: input.assistantMessage,
  109. sessionID: input.sessionID,
  110. model: input.model,
  111. toolcalls: {},
  112. shouldBreak: false,
  113. snapshot: initialSnapshot,
  114. blocked: false,
  115. needsCompaction: false,
  116. currentText: undefined,
  117. currentTextID: undefined,
  118. reasoningMap: {},
  119. v2AssistantMessageID: undefined,
  120. }
  121. let aborted = false
  122. const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
  123. const parse = (e: unknown) =>
  124. MessageV2.fromError(e, {
  125. providerID: input.model.providerID,
  126. aborted,
  127. })
  128. const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
  129. const done = ctx.toolcalls[toolCallID]?.done
  130. delete ctx.toolcalls[toolCallID]
  131. if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
  132. })
  133. const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
  134. if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
  135. ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
  136. sessionID: ctx.sessionID,
  137. agent: input.assistantMessage.agent,
  138. model: {
  139. id: ModelV2.ID.make(ctx.model.id),
  140. providerID: ProviderV2.ID.make(ctx.model.providerID),
  141. variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
  142. },
  143. snapshot: ctx.snapshot,
  144. timestamp: DateTime.makeUnsafe(Date.now()),
  145. })).id
  146. return ctx.v2AssistantMessageID
  147. })
  148. const requireV2AssistantMessage = (toolCall?: ToolCall) =>
  149. toolCall?.assistantMessageID === undefined
  150. ? Effect.die("V2 tool settlement has no owning assistant message")
  151. : Effect.succeed(toolCall.assistantMessageID)
  152. const currentV2AssistantMessage = () =>
  153. ctx.v2AssistantMessageID === undefined
  154. ? Effect.die("V2 step settlement has no owning assistant message")
  155. : Effect.succeed(ctx.v2AssistantMessageID)
  156. const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
  157. const call = ctx.toolcalls[toolCallID]
  158. if (!call) return undefined
  159. const part = yield* session.getPart({
  160. partID: call.partID,
  161. messageID: call.messageID,
  162. sessionID: call.sessionID,
  163. })
  164. if (!part || part.type !== "tool") {
  165. delete ctx.toolcalls[toolCallID]
  166. return undefined
  167. }
  168. return { call, part }
  169. })
  170. const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
  171. toolCallID: string,
  172. update: (part: SessionV1.ToolPart) => SessionV1.ToolPart,
  173. ) {
  174. const match = yield* readToolCall(toolCallID)
  175. if (!match) return undefined
  176. const part = yield* session.updatePart(update(match.part))
  177. ctx.toolcalls[toolCallID] = {
  178. ...match.call,
  179. partID: part.id,
  180. messageID: part.messageID,
  181. sessionID: part.sessionID,
  182. }
  183. return part
  184. })
  185. const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
  186. toolCallID: string,
  187. output: {
  188. title: string
  189. metadata: Record<string, any>
  190. output: string
  191. attachments?: SessionV1.FilePart[]
  192. },
  193. ) {
  194. const match = yield* readToolCall(toolCallID)
  195. if (!match || match.part.state.status !== "running") return
  196. yield* session.updatePart({
  197. ...match.part,
  198. state: {
  199. status: "completed",
  200. input: match.part.state.input,
  201. output: output.output,
  202. metadata: output.metadata,
  203. title: output.title,
  204. time: { start: match.part.state.time.start, end: Date.now() },
  205. attachments: output.attachments,
  206. },
  207. })
  208. yield* settleToolCall(toolCallID)
  209. })
  210. const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
  211. const match = yield* readToolCall(toolCallID)
  212. if (!match || match.part.state.status !== "running") return false
  213. yield* session.updatePart({
  214. ...match.part,
  215. state: {
  216. status: "error",
  217. input: match.part.state.input,
  218. error: errorMessage(error),
  219. time: { start: match.part.state.time.start, end: Date.now() },
  220. },
  221. })
  222. if (error instanceof PermissionV1.RejectedError || error instanceof Question.RejectedError) {
  223. ctx.blocked = ctx.shouldBreak
  224. }
  225. yield* settleToolCall(toolCallID)
  226. return true
  227. })
  228. const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
  229. if (!(reasoningID in ctx.reasoningMap)) return
  230. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  231. if (flags.experimentalEventSystem) {
  232. yield* events.publish(SessionEvent.Reasoning.Ended, {
  233. sessionID: ctx.sessionID,
  234. reasoningID,
  235. text: ctx.reasoningMap[reasoningID].text,
  236. providerMetadata: ctx.reasoningMap[reasoningID].metadata,
  237. timestamp: DateTime.makeUnsafe(Date.now()),
  238. })
  239. }
  240. // oxlint-disable-next-line no-self-assign -- reactivity trigger
  241. ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
  242. ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
  243. yield* session.updatePart(ctx.reasoningMap[reasoningID])
  244. delete ctx.reasoningMap[reasoningID]
  245. })
  246. const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
  247. if (!flags.experimentalEventSystem) return
  248. if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
  249. yield* events.publish(SessionEvent.Text.Ended, {
  250. sessionID: ctx.sessionID,
  251. textID: ctx.currentTextID,
  252. text: ctx.currentText.text,
  253. timestamp: DateTime.makeUnsafe(Date.now()),
  254. })
  255. }
  256. yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
  257. events.publish(SessionEvent.Reasoning.Ended, {
  258. sessionID: ctx.sessionID,
  259. reasoningID,
  260. text: part.text,
  261. providerMetadata: part.metadata,
  262. timestamp: DateTime.makeUnsafe(Date.now()),
  263. }),
  264. )
  265. })
  266. const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
  267. id: string
  268. name: string
  269. providerExecuted?: boolean
  270. }) {
  271. const existing = yield* readToolCall(input.id)
  272. if (existing) {
  273. if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
  274. const part = yield* session.updatePart({
  275. ...existing.part,
  276. metadata: { ...existing.part.metadata, providerExecuted: true },
  277. })
  278. ctx.toolcalls[input.id] = {
  279. ...existing.call,
  280. partID: part.id,
  281. messageID: part.messageID,
  282. sessionID: part.sessionID,
  283. }
  284. return { call: ctx.toolcalls[input.id], part }
  285. }
  286. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  287. const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
  288. if (assistantMessageID) {
  289. yield* events.publish(SessionEvent.Tool.Input.Started, {
  290. sessionID: ctx.sessionID,
  291. assistantMessageID,
  292. callID: input.id,
  293. name: input.name,
  294. timestamp: DateTime.makeUnsafe(Date.now()),
  295. })
  296. }
  297. const part = yield* session.updatePart({
  298. id: PartID.ascending(),
  299. messageID: ctx.assistantMessage.id,
  300. sessionID: ctx.assistantMessage.sessionID,
  301. type: "tool",
  302. tool: input.name,
  303. callID: input.id,
  304. state: { status: "pending", input: {}, raw: "" },
  305. metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
  306. } satisfies SessionV1.ToolPart)
  307. ctx.toolcalls[input.id] = {
  308. assistantMessageID,
  309. done: yield* Deferred.make<void>(),
  310. partID: part.id,
  311. messageID: part.messageID,
  312. sessionID: part.sessionID,
  313. inputEnded: false,
  314. raw: "",
  315. }
  316. return { call: ctx.toolcalls[input.id], part }
  317. })
  318. const isFilePart = (value: unknown): value is SessionV1.FilePart => Schema.is(SessionV1.FilePart)(value)
  319. const toolResultOutput = (
  320. value: Extract<StreamEvent, { type: "tool-result" }>,
  321. ): { title: string; metadata: Record<string, any>; output: string; attachments?: SessionV1.FilePart[] } => {
  322. if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
  323. return {
  324. title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
  325. metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
  326. output: value.result.value.output,
  327. attachments: Array.isArray(value.result.value.attachments)
  328. ? value.result.value.attachments.filter(isFilePart)
  329. : undefined,
  330. }
  331. }
  332. return {
  333. title: value.name,
  334. metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
  335. output:
  336. typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
  337. }
  338. }
  339. const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
  340. switch (value.type) {
  341. case "reasoning-start":
  342. if (value.id in ctx.reasoningMap) return
  343. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  344. if (flags.experimentalEventSystem) {
  345. yield* events.publish(SessionEvent.Reasoning.Started, {
  346. sessionID: ctx.sessionID,
  347. reasoningID: value.id,
  348. providerMetadata: value.providerMetadata,
  349. timestamp: DateTime.makeUnsafe(Date.now()),
  350. })
  351. }
  352. ctx.reasoningMap[value.id] = {
  353. id: PartID.ascending(),
  354. messageID: ctx.assistantMessage.id,
  355. sessionID: ctx.assistantMessage.sessionID,
  356. type: "reasoning",
  357. text: "",
  358. time: { start: Date.now() },
  359. metadata: value.providerMetadata,
  360. }
  361. yield* session.updatePart(ctx.reasoningMap[value.id])
  362. return
  363. case "reasoning-delta":
  364. // Match dev: silently drop orphan deltas (no preceding reasoning-start).
  365. if (!(value.id in ctx.reasoningMap)) return
  366. ctx.reasoningMap[value.id].text += value.text
  367. if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
  368. if (flags.experimentalEventSystem) {
  369. yield* events.publish(SessionEvent.Reasoning.Delta, {
  370. sessionID: ctx.sessionID,
  371. reasoningID: value.id,
  372. delta: value.text,
  373. timestamp: DateTime.makeUnsafe(Date.now()),
  374. })
  375. }
  376. yield* session.updatePartDelta({
  377. sessionID: ctx.reasoningMap[value.id].sessionID,
  378. messageID: ctx.reasoningMap[value.id].messageID,
  379. partID: ctx.reasoningMap[value.id].id,
  380. field: "text",
  381. delta: value.text,
  382. })
  383. return
  384. case "reasoning-end":
  385. if (value.providerMetadata && value.id in ctx.reasoningMap) {
  386. ctx.reasoningMap[value.id].metadata = value.providerMetadata
  387. }
  388. yield* finishReasoning(value.id)
  389. return
  390. case "tool-input-start":
  391. if (ctx.assistantMessage.summary) {
  392. throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
  393. }
  394. yield* ensureToolCall(value)
  395. return
  396. case "tool-input-delta":
  397. {
  398. const toolCall = yield* ensureToolCall(value)
  399. const assistantMessageID = flags.experimentalEventSystem
  400. ? yield* requireV2AssistantMessage(toolCall.call)
  401. : undefined
  402. if (assistantMessageID) {
  403. yield* events.publish(SessionEvent.Tool.Input.Delta, {
  404. sessionID: ctx.sessionID,
  405. assistantMessageID,
  406. callID: value.id,
  407. delta: value.text,
  408. timestamp: DateTime.makeUnsafe(Date.now()),
  409. })
  410. }
  411. ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text }
  412. }
  413. return
  414. case "tool-input-end": {
  415. const toolCall = yield* ensureToolCall(value)
  416. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  417. if (flags.experimentalEventSystem) {
  418. const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
  419. yield* events.publish(SessionEvent.Tool.Input.Ended, {
  420. sessionID: ctx.sessionID,
  421. assistantMessageID,
  422. callID: value.id,
  423. text: toolCall.call.raw,
  424. timestamp: DateTime.makeUnsafe(Date.now()),
  425. })
  426. }
  427. ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
  428. return
  429. }
  430. case "tool-call": {
  431. if (ctx.assistantMessage.summary) {
  432. throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
  433. }
  434. const toolCall = yield* ensureToolCall(value)
  435. const input = isRecord(value.input) ? value.input : { value: value.input }
  436. if (!toolCall.call.inputEnded) {
  437. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  438. if (flags.experimentalEventSystem) {
  439. const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
  440. yield* events.publish(SessionEvent.Tool.Input.Ended, {
  441. sessionID: ctx.sessionID,
  442. assistantMessageID,
  443. callID: value.id,
  444. text: toolCall.call.raw,
  445. timestamp: DateTime.makeUnsafe(Date.now()),
  446. })
  447. }
  448. }
  449. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  450. if (flags.experimentalEventSystem) {
  451. const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
  452. yield* events.publish(SessionEvent.Tool.Called, {
  453. sessionID: ctx.sessionID,
  454. assistantMessageID,
  455. callID: value.id,
  456. tool: value.name,
  457. input,
  458. provider: {
  459. executed: toolCall.part.metadata?.providerExecuted === true,
  460. ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
  461. },
  462. timestamp: DateTime.makeUnsafe(Date.now()),
  463. })
  464. }
  465. yield* updateToolCall(value.id, (match) => ({
  466. ...match,
  467. tool: value.name,
  468. state:
  469. match.state.status === "running"
  470. ? { ...match.state, input }
  471. : {
  472. status: "running",
  473. input,
  474. time: { start: Date.now() },
  475. },
  476. metadata: match.metadata?.providerExecuted
  477. ? { ...value.providerMetadata, providerExecuted: true }
  478. : value.providerMetadata,
  479. }))
  480. const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
  481. Effect.provideService(Database.Service, database),
  482. )
  483. const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
  484. if (
  485. recentParts.length !== DOOM_LOOP_THRESHOLD ||
  486. !recentParts.every(
  487. (part) =>
  488. part.type === "tool" &&
  489. part.tool === value.name &&
  490. part.state.status !== "pending" &&
  491. JSON.stringify(part.state.input) === JSON.stringify(input),
  492. )
  493. ) {
  494. return
  495. }
  496. const agent = yield* agents.get(ctx.assistantMessage.agent)
  497. yield* permission.ask({
  498. permission: "doom_loop",
  499. patterns: [value.name],
  500. sessionID: ctx.assistantMessage.sessionID,
  501. metadata: { tool: value.name, input },
  502. always: [value.name],
  503. ruleset: agent.permission,
  504. })
  505. return
  506. }
  507. case "tool-result": {
  508. const toolCall = yield* readToolCall(value.id)
  509. if (!toolCall && value.result.type === "error") return
  510. if (value.result.type === "error") {
  511. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  512. if (flags.experimentalEventSystem) {
  513. const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
  514. yield* events.publish(SessionEvent.Tool.Failed, {
  515. sessionID: ctx.sessionID,
  516. assistantMessageID,
  517. callID: value.id,
  518. error: { type: "unknown", message: errorMessage(value.result.value) },
  519. result: value.result,
  520. provider: {
  521. executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
  522. ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
  523. },
  524. timestamp: DateTime.makeUnsafe(Date.now()),
  525. })
  526. }
  527. yield* failToolCall(value.id, value.result.value)
  528. return
  529. }
  530. const rawOutput = toolResultOutput(value)
  531. const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
  532. attachment.mime.startsWith("image/")
  533. ? image.normalize(attachment).pipe(
  534. Effect.catchIf(
  535. (error) => error instanceof Image.ResizerUnavailableError,
  536. () => Effect.succeed(attachment),
  537. ),
  538. Effect.exit,
  539. )
  540. : Effect.succeed(Exit.succeed<SessionV1.FilePart>(attachment)),
  541. )
  542. const omitted = normalized.filter(Exit.isFailure).length
  543. const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
  544. const output = {
  545. ...rawOutput,
  546. output:
  547. omitted === 0
  548. ? rawOutput.output
  549. : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
  550. attachments: attachments.length ? attachments : undefined,
  551. }
  552. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  553. if (flags.experimentalEventSystem) {
  554. const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
  555. const content = [
  556. ToolOutput.text({ type: "text", text: output.output }),
  557. ...(output.attachments?.map((item: SessionV1.FilePart) =>
  558. ToolOutput.file({
  559. type: "file",
  560. source: toolFileSourceFromUri(item.url),
  561. mime: item.mime,
  562. name: item.filename,
  563. }),
  564. ) ?? []),
  565. ]
  566. const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
  567. if (unsupported?.type === "file") {
  568. const error = new Error(
  569. `Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`,
  570. )
  571. yield* events.publish(SessionEvent.Tool.Failed, {
  572. sessionID: ctx.sessionID,
  573. assistantMessageID,
  574. callID: value.id,
  575. error: {
  576. type: "unknown",
  577. message: error.message,
  578. },
  579. provider: {
  580. executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
  581. ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
  582. },
  583. timestamp: DateTime.makeUnsafe(Date.now()),
  584. })
  585. yield* failToolCall(value.id, error)
  586. return
  587. } else
  588. yield* events.publish(SessionEvent.Tool.Success, {
  589. sessionID: ctx.sessionID,
  590. assistantMessageID,
  591. callID: value.id,
  592. structured: output.metadata,
  593. content,
  594. result: value.result,
  595. provider: {
  596. executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
  597. ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
  598. },
  599. timestamp: DateTime.makeUnsafe(Date.now()),
  600. })
  601. }
  602. yield* completeToolCall(value.id, output)
  603. return
  604. }
  605. case "tool-error": {
  606. const toolCall = yield* readToolCall(value.id)
  607. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  608. if (flags.experimentalEventSystem) {
  609. const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
  610. yield* events.publish(SessionEvent.Tool.Failed, {
  611. sessionID: ctx.sessionID,
  612. assistantMessageID,
  613. callID: value.id,
  614. error: {
  615. type: "unknown",
  616. message: value.message,
  617. },
  618. provider: {
  619. executed: toolCall?.part.metadata?.providerExecuted === true,
  620. ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
  621. },
  622. timestamp: DateTime.makeUnsafe(Date.now()),
  623. })
  624. }
  625. yield* failToolCall(value.id, value.error ?? new Error(value.message))
  626. return
  627. }
  628. case "provider-error":
  629. throw new Error(value.message)
  630. case "step-start":
  631. if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
  632. if (!ctx.assistantMessage.summary) {
  633. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  634. if (flags.experimentalEventSystem) {
  635. yield* ensureV2AssistantMessage()
  636. }
  637. }
  638. yield* session.updatePart({
  639. id: PartID.ascending(),
  640. messageID: ctx.assistantMessage.id,
  641. sessionID: ctx.sessionID,
  642. snapshot: ctx.snapshot,
  643. type: "step-start",
  644. })
  645. return
  646. case "step-finish": {
  647. const completedSnapshot = yield* snapshot.track()
  648. yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
  649. const usage = Session.getUsage({
  650. model: ctx.model,
  651. usage: value.usage ?? new Usage({}),
  652. metadata: value.providerMetadata,
  653. })
  654. if (!ctx.assistantMessage.summary) {
  655. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  656. if (flags.experimentalEventSystem) {
  657. yield* events.publish(SessionEvent.Step.Ended, {
  658. sessionID: ctx.sessionID,
  659. assistantMessageID: yield* currentV2AssistantMessage(),
  660. finish: value.reason,
  661. cost: usage.cost,
  662. tokens: usage.tokens,
  663. snapshot: completedSnapshot,
  664. timestamp: DateTime.makeUnsafe(Date.now()),
  665. })
  666. ctx.v2AssistantMessageID = undefined
  667. }
  668. }
  669. ctx.assistantMessage.finish = value.reason
  670. ctx.assistantMessage.cost += usage.cost
  671. ctx.assistantMessage.tokens = usage.tokens
  672. yield* session.updatePart({
  673. id: PartID.ascending(),
  674. reason: value.reason,
  675. snapshot: completedSnapshot,
  676. messageID: ctx.assistantMessage.id,
  677. sessionID: ctx.assistantMessage.sessionID,
  678. type: "step-finish",
  679. tokens: usage.tokens,
  680. cost: usage.cost,
  681. })
  682. yield* session.updateMessage(ctx.assistantMessage)
  683. if (ctx.snapshot) {
  684. const patch = yield* snapshot.patch(ctx.snapshot)
  685. if (patch.files.length) {
  686. yield* session.updatePart({
  687. id: PartID.ascending(),
  688. messageID: ctx.assistantMessage.id,
  689. sessionID: ctx.sessionID,
  690. type: "patch",
  691. hash: patch.hash,
  692. files: patch.files,
  693. })
  694. }
  695. ctx.snapshot = undefined
  696. }
  697. yield* summary
  698. .summarize({
  699. sessionID: ctx.sessionID,
  700. messageID: ctx.assistantMessage.parentID,
  701. })
  702. .pipe(Effect.ignore, Effect.forkIn(scope))
  703. if (
  704. !ctx.assistantMessage.summary &&
  705. isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
  706. ) {
  707. ctx.needsCompaction = true
  708. }
  709. return
  710. }
  711. case "text-start":
  712. if (!ctx.assistantMessage.summary) {
  713. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  714. if (flags.experimentalEventSystem) {
  715. yield* events.publish(SessionEvent.Text.Started, {
  716. sessionID: ctx.sessionID,
  717. timestamp: DateTime.makeUnsafe(Date.now()),
  718. textID: value.id,
  719. })
  720. }
  721. }
  722. ctx.currentText = {
  723. id: PartID.ascending(),
  724. messageID: ctx.assistantMessage.id,
  725. sessionID: ctx.assistantMessage.sessionID,
  726. type: "text",
  727. text: "",
  728. time: { start: Date.now() },
  729. metadata: value.providerMetadata,
  730. }
  731. ctx.currentTextID = value.id
  732. yield* session.updatePart(ctx.currentText)
  733. return
  734. case "text-delta":
  735. if (!ctx.currentText) return
  736. ctx.currentText.text += value.text
  737. if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
  738. if (flags.experimentalEventSystem) {
  739. yield* events.publish(SessionEvent.Text.Delta, {
  740. sessionID: ctx.sessionID,
  741. textID: value.id,
  742. delta: value.text,
  743. timestamp: DateTime.makeUnsafe(Date.now()),
  744. })
  745. }
  746. yield* session.updatePartDelta({
  747. sessionID: ctx.currentText.sessionID,
  748. messageID: ctx.currentText.messageID,
  749. partID: ctx.currentText.id,
  750. field: "text",
  751. delta: value.text,
  752. })
  753. return
  754. case "text-end":
  755. if (!ctx.currentText) return
  756. // oxlint-disable-next-line no-self-assign -- reactivity trigger
  757. ctx.currentText.text = ctx.currentText.text
  758. ctx.currentText.text = (yield* plugin.trigger(
  759. "experimental.text.complete",
  760. {
  761. sessionID: ctx.sessionID,
  762. messageID: ctx.assistantMessage.id,
  763. partID: ctx.currentText.id,
  764. },
  765. { text: ctx.currentText.text },
  766. )).text
  767. if (!ctx.assistantMessage.summary) {
  768. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  769. if (flags.experimentalEventSystem) {
  770. yield* events.publish(SessionEvent.Text.Ended, {
  771. sessionID: ctx.sessionID,
  772. text: ctx.currentText.text,
  773. timestamp: DateTime.makeUnsafe(Date.now()),
  774. textID: value.id,
  775. })
  776. }
  777. }
  778. {
  779. const end = Date.now()
  780. ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
  781. }
  782. if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
  783. yield* session.updatePart(ctx.currentText)
  784. ctx.currentText = undefined
  785. ctx.currentTextID = undefined
  786. return
  787. case "finish":
  788. return
  789. }
  790. })
  791. const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
  792. if (ctx.snapshot) {
  793. const patch = yield* snapshot.patch(ctx.snapshot)
  794. if (patch.files.length) {
  795. yield* session.updatePart({
  796. id: PartID.ascending(),
  797. messageID: ctx.assistantMessage.id,
  798. sessionID: ctx.sessionID,
  799. type: "patch",
  800. hash: patch.hash,
  801. files: patch.files,
  802. })
  803. }
  804. ctx.snapshot = undefined
  805. }
  806. if (ctx.currentText) {
  807. const end = Date.now()
  808. ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
  809. yield* session.updatePart(ctx.currentText)
  810. ctx.currentText = undefined
  811. ctx.currentTextID = undefined
  812. }
  813. for (const part of Object.values(ctx.reasoningMap)) {
  814. const end = Date.now()
  815. yield* session.updatePart({
  816. ...part,
  817. time: { start: part.time.start ?? end, end },
  818. })
  819. }
  820. ctx.reasoningMap = {}
  821. yield* Effect.forEach(
  822. Object.values(ctx.toolcalls),
  823. (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
  824. { concurrency: "unbounded" },
  825. )
  826. for (const toolCallID of Object.keys(ctx.toolcalls)) {
  827. const match = yield* readToolCall(toolCallID)
  828. if (!match) continue
  829. const part = match.part
  830. if (flags.experimentalEventSystem && match.call.assistantMessageID) {
  831. yield* events.publish(SessionEvent.Tool.Failed, {
  832. sessionID: ctx.sessionID,
  833. assistantMessageID: match.call.assistantMessageID,
  834. callID: toolCallID,
  835. error: { type: "unknown", message: "Tool execution aborted" },
  836. provider: { executed: part.metadata?.providerExecuted === true },
  837. timestamp: DateTime.makeUnsafe(Date.now()),
  838. })
  839. }
  840. const end = Date.now()
  841. const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
  842. yield* session.updatePart({
  843. ...part,
  844. state: {
  845. ...part.state,
  846. status: "error",
  847. error: "Tool execution aborted",
  848. metadata: { ...metadata, interrupted: true },
  849. time: { start: "time" in part.state ? part.state.time.start : end, end },
  850. },
  851. })
  852. }
  853. ctx.toolcalls = {}
  854. ctx.assistantMessage.time.completed = Date.now()
  855. yield* session.updateMessage(ctx.assistantMessage)
  856. })
  857. const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
  858. slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
  859. const error = parse(e)
  860. yield* flushV2Fragments()
  861. if (SessionV1.ContextOverflowError.isInstance(error)) {
  862. if ((yield* config.get()).compaction?.auto === false && !ctx.assistantMessage.summary) {
  863. ctx.assistantMessage.error = error
  864. ctx.assistantMessage.finish = "error"
  865. yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
  866. yield* status.set(ctx.sessionID, { type: "idle" })
  867. return
  868. }
  869. ctx.needsCompaction = true
  870. yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
  871. return
  872. }
  873. if (!ctx.assistantMessage.summary) {
  874. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  875. if (flags.experimentalEventSystem) {
  876. yield* events.publish(SessionEvent.Step.Failed, {
  877. sessionID: ctx.sessionID,
  878. assistantMessageID: yield* ensureV2AssistantMessage(),
  879. error: {
  880. type: "unknown",
  881. message: errorMessage(e),
  882. },
  883. timestamp: DateTime.makeUnsafe(Date.now()),
  884. })
  885. }
  886. }
  887. ctx.assistantMessage.error = error
  888. yield* events.publish(Session.Event.Error, {
  889. sessionID: ctx.assistantMessage.sessionID,
  890. error: ctx.assistantMessage.error,
  891. })
  892. yield* status.set(ctx.sessionID, { type: "idle" })
  893. })
  894. const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
  895. slog.info("process")
  896. ctx.needsCompaction = false
  897. ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
  898. return yield* Effect.gen(function* () {
  899. yield* Effect.gen(function* () {
  900. ctx.currentText = undefined
  901. ctx.currentTextID = undefined
  902. ctx.reasoningMap = {}
  903. yield* status.set(ctx.sessionID, { type: "busy" })
  904. const stream = llm.stream(streamInput)
  905. yield* stream.pipe(
  906. Stream.tap((event) => handleEvent(event)),
  907. Stream.takeUntil(() => ctx.needsCompaction),
  908. Stream.runDrain,
  909. )
  910. }).pipe(
  911. Effect.onInterrupt(() =>
  912. Effect.gen(function* () {
  913. aborted = true
  914. if (!ctx.assistantMessage.error) {
  915. yield* halt(new DOMException("Aborted", "AbortError"))
  916. }
  917. }),
  918. ),
  919. Effect.catchCauseIf(
  920. (cause) => !Cause.hasInterruptsOnly(cause),
  921. (cause) => Effect.fail(Cause.squash(cause)),
  922. ),
  923. Effect.retry(
  924. SessionRetry.policy({
  925. provider: input.model.providerID,
  926. parse,
  927. set: (info) => {
  928. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
  929. const event = flags.experimentalEventSystem
  930. ? events.publish(SessionEvent.Retried, {
  931. sessionID: ctx.sessionID,
  932. attempt: info.attempt,
  933. error: {
  934. message: info.message,
  935. isRetryable: true,
  936. },
  937. timestamp: DateTime.makeUnsafe(Date.now()),
  938. })
  939. : Effect.void
  940. return flushV2Fragments().pipe(
  941. Effect.andThen(event),
  942. Effect.andThen(
  943. status.set(ctx.sessionID, {
  944. type: "retry",
  945. attempt: info.attempt,
  946. message: info.message,
  947. action: info.action,
  948. next: info.next,
  949. }),
  950. ),
  951. )
  952. },
  953. }),
  954. ),
  955. Effect.catch(halt),
  956. Effect.ensuring(cleanup()),
  957. )
  958. if (ctx.needsCompaction) return "compact"
  959. if (ctx.blocked || ctx.assistantMessage.error) return "stop"
  960. return "continue"
  961. })
  962. })
  963. return {
  964. get message() {
  965. return ctx.assistantMessage
  966. },
  967. updateToolCall,
  968. completeToolCall,
  969. process,
  970. } satisfies Handle
  971. })
  972. return Service.of({ create })
  973. }),
  974. )
  975. export const defaultLayer = Layer.suspend(() =>
  976. layer.pipe(
  977. Layer.provide(Session.defaultLayer),
  978. Layer.provide(Snapshot.defaultLayer),
  979. Layer.provide(Agent.defaultLayer),
  980. Layer.provide(LLM.defaultLayer),
  981. Layer.provide(Permission.defaultLayer),
  982. Layer.provide(Plugin.defaultLayer),
  983. Layer.provide(SessionSummary.defaultLayer),
  984. Layer.provide(SessionStatus.defaultLayer),
  985. Layer.provide(Image.defaultLayer),
  986. Layer.provide(Config.defaultLayer),
  987. Layer.provide(RuntimeFlags.defaultLayer),
  988. Layer.provide(Database.defaultLayer),
  989. Layer.provide(EventV2Bridge.defaultLayer),
  990. ),
  991. )
  992. export * as SessionProcessor from "./processor"