processor.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { Cause, Effect, Layer, ServiceMap } from "effect"
  2. import * as Stream from "effect/Stream"
  3. import { Agent } from "@/agent/agent"
  4. import { Bus } from "@/bus"
  5. import { Config } from "@/config/config"
  6. import { Permission } from "@/permission"
  7. import { Plugin } from "@/plugin"
  8. import { Snapshot } from "@/snapshot"
  9. import { Log } from "@/util/log"
  10. import { Session } from "."
  11. import { LLM } from "./llm"
  12. import { MessageV2 } from "./message-v2"
  13. import { isOverflow } from "./overflow"
  14. import { PartID } from "./schema"
  15. import type { SessionID } from "./schema"
  16. import { SessionRetry } from "./retry"
  17. import { SessionStatus } from "./status"
  18. import { SessionSummary } from "./summary"
  19. import type { Provider } from "@/provider/provider"
  20. import { Question } from "@/question"
  21. import { isRecord } from "@/util/record"
  22. export namespace SessionProcessor {
  23. const DOOM_LOOP_THRESHOLD = 3
  24. const log = Log.create({ service: "session.processor" })
  25. export type Result = "compact" | "stop" | "continue"
  26. export type Event = LLM.Event
  27. export interface Handle {
  28. readonly message: MessageV2.Assistant
  29. readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
  30. readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
  31. }
  32. type Input = {
  33. assistantMessage: MessageV2.Assistant
  34. sessionID: SessionID
  35. model: Provider.Model
  36. }
  37. export interface Interface {
  38. readonly create: (input: Input) => Effect.Effect<Handle>
  39. }
  40. interface ProcessorContext extends Input {
  41. toolcalls: Record<string, MessageV2.ToolPart>
  42. shouldBreak: boolean
  43. snapshot: string | undefined
  44. blocked: boolean
  45. needsCompaction: boolean
  46. currentText: MessageV2.TextPart | undefined
  47. reasoningMap: Record<string, MessageV2.ReasoningPart>
  48. }
  49. type StreamEvent = Event
  50. export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionProcessor") {}
  51. export const layer: Layer.Layer<
  52. Service,
  53. never,
  54. | Session.Service
  55. | Config.Service
  56. | Bus.Service
  57. | Snapshot.Service
  58. | Agent.Service
  59. | LLM.Service
  60. | Permission.Service
  61. | Plugin.Service
  62. | SessionStatus.Service
  63. > = Layer.effect(
  64. Service,
  65. Effect.gen(function* () {
  66. const session = yield* Session.Service
  67. const config = yield* Config.Service
  68. const bus = yield* Bus.Service
  69. const snapshot = yield* Snapshot.Service
  70. const agents = yield* Agent.Service
  71. const llm = yield* LLM.Service
  72. const permission = yield* Permission.Service
  73. const plugin = yield* Plugin.Service
  74. const status = yield* SessionStatus.Service
  75. const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
  76. // Pre-capture snapshot before the LLM stream starts. The AI SDK
  77. // may execute tools internally before emitting start-step events,
  78. // so capturing inside the event handler can be too late.
  79. const initialSnapshot = yield* snapshot.track()
  80. const ctx: ProcessorContext = {
  81. assistantMessage: input.assistantMessage,
  82. sessionID: input.sessionID,
  83. model: input.model,
  84. toolcalls: {},
  85. shouldBreak: false,
  86. snapshot: initialSnapshot,
  87. blocked: false,
  88. needsCompaction: false,
  89. currentText: undefined,
  90. reasoningMap: {},
  91. }
  92. let aborted = false
  93. const parse = (e: unknown) =>
  94. MessageV2.fromError(e, {
  95. providerID: input.model.providerID,
  96. aborted,
  97. })
  98. const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
  99. switch (value.type) {
  100. case "start":
  101. yield* status.set(ctx.sessionID, { type: "busy" })
  102. return
  103. case "reasoning-start":
  104. if (value.id in ctx.reasoningMap) return
  105. ctx.reasoningMap[value.id] = {
  106. id: PartID.ascending(),
  107. messageID: ctx.assistantMessage.id,
  108. sessionID: ctx.assistantMessage.sessionID,
  109. type: "reasoning",
  110. text: "",
  111. time: { start: Date.now() },
  112. metadata: value.providerMetadata,
  113. }
  114. yield* session.updatePart(ctx.reasoningMap[value.id])
  115. return
  116. case "reasoning-delta":
  117. if (!(value.id in ctx.reasoningMap)) return
  118. ctx.reasoningMap[value.id].text += value.text
  119. if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
  120. yield* session.updatePartDelta({
  121. sessionID: ctx.reasoningMap[value.id].sessionID,
  122. messageID: ctx.reasoningMap[value.id].messageID,
  123. partID: ctx.reasoningMap[value.id].id,
  124. field: "text",
  125. delta: value.text,
  126. })
  127. return
  128. case "reasoning-end":
  129. if (!(value.id in ctx.reasoningMap)) return
  130. ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
  131. ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
  132. if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
  133. yield* session.updatePart(ctx.reasoningMap[value.id])
  134. delete ctx.reasoningMap[value.id]
  135. return
  136. case "tool-input-start":
  137. if (ctx.assistantMessage.summary) {
  138. throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
  139. }
  140. ctx.toolcalls[value.id] = yield* session.updatePart({
  141. id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
  142. messageID: ctx.assistantMessage.id,
  143. sessionID: ctx.assistantMessage.sessionID,
  144. type: "tool",
  145. tool: value.toolName,
  146. callID: value.id,
  147. state: { status: "pending", input: {}, raw: "" },
  148. metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
  149. } satisfies MessageV2.ToolPart)
  150. return
  151. case "tool-input-delta":
  152. return
  153. case "tool-input-end":
  154. return
  155. case "tool-call": {
  156. if (ctx.assistantMessage.summary) {
  157. throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
  158. }
  159. const pointer = ctx.toolcalls[value.toolCallId]
  160. const match = yield* session.getPart({
  161. partID: pointer.id,
  162. messageID: pointer.messageID,
  163. sessionID: pointer.sessionID,
  164. })
  165. if (!match || match.type !== "tool") return
  166. ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
  167. ...match,
  168. tool: value.toolName,
  169. state: {
  170. ...match.state,
  171. status: "running",
  172. input: value.input,
  173. time: { start: Date.now() },
  174. },
  175. metadata: match.metadata?.providerExecuted
  176. ? { ...value.providerMetadata, providerExecuted: true }
  177. : value.providerMetadata,
  178. } satisfies MessageV2.ToolPart)
  179. const parts = MessageV2.parts(ctx.assistantMessage.id)
  180. const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
  181. if (
  182. recentParts.length !== DOOM_LOOP_THRESHOLD ||
  183. !recentParts.every(
  184. (part) =>
  185. part.type === "tool" &&
  186. part.tool === value.toolName &&
  187. part.state.status !== "pending" &&
  188. JSON.stringify(part.state.input) === JSON.stringify(value.input),
  189. )
  190. ) {
  191. return
  192. }
  193. const agent = yield* agents.get(ctx.assistantMessage.agent)
  194. yield* permission.ask({
  195. permission: "doom_loop",
  196. patterns: [value.toolName],
  197. sessionID: ctx.assistantMessage.sessionID,
  198. metadata: { tool: value.toolName, input: value.input },
  199. always: [value.toolName],
  200. ruleset: agent.permission,
  201. })
  202. return
  203. }
  204. case "tool-result": {
  205. const match = ctx.toolcalls[value.toolCallId]
  206. if (!match || match.state.status !== "running") return
  207. yield* session.updatePart({
  208. ...match,
  209. state: {
  210. status: "completed",
  211. input: value.input ?? match.state.input,
  212. output: value.output.output,
  213. metadata: value.output.metadata,
  214. title: value.output.title,
  215. time: { start: match.state.time.start, end: Date.now() },
  216. attachments: value.output.attachments,
  217. },
  218. })
  219. delete ctx.toolcalls[value.toolCallId]
  220. return
  221. }
  222. case "tool-error": {
  223. const match = ctx.toolcalls[value.toolCallId]
  224. if (!match || match.state.status !== "running") return
  225. yield* session.updatePart({
  226. ...match,
  227. state: {
  228. status: "error",
  229. input: value.input ?? match.state.input,
  230. error: value.error instanceof Error ? value.error.message : String(value.error),
  231. time: { start: match.state.time.start, end: Date.now() },
  232. },
  233. })
  234. if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
  235. ctx.blocked = ctx.shouldBreak
  236. }
  237. delete ctx.toolcalls[value.toolCallId]
  238. return
  239. }
  240. case "error":
  241. throw value.error
  242. case "start-step":
  243. if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
  244. yield* session.updatePart({
  245. id: PartID.ascending(),
  246. messageID: ctx.assistantMessage.id,
  247. sessionID: ctx.sessionID,
  248. snapshot: ctx.snapshot,
  249. type: "step-start",
  250. })
  251. return
  252. case "finish-step": {
  253. const usage = Session.getUsage({
  254. model: ctx.model,
  255. usage: value.usage,
  256. metadata: value.providerMetadata,
  257. })
  258. ctx.assistantMessage.finish = value.finishReason
  259. ctx.assistantMessage.cost += usage.cost
  260. ctx.assistantMessage.tokens = usage.tokens
  261. yield* session.updatePart({
  262. id: PartID.ascending(),
  263. reason: value.finishReason,
  264. snapshot: yield* snapshot.track(),
  265. messageID: ctx.assistantMessage.id,
  266. sessionID: ctx.assistantMessage.sessionID,
  267. type: "step-finish",
  268. tokens: usage.tokens,
  269. cost: usage.cost,
  270. })
  271. yield* session.updateMessage(ctx.assistantMessage)
  272. if (ctx.snapshot) {
  273. const patch = yield* snapshot.patch(ctx.snapshot)
  274. if (patch.files.length) {
  275. yield* session.updatePart({
  276. id: PartID.ascending(),
  277. messageID: ctx.assistantMessage.id,
  278. sessionID: ctx.sessionID,
  279. type: "patch",
  280. hash: patch.hash,
  281. files: patch.files,
  282. })
  283. }
  284. ctx.snapshot = undefined
  285. }
  286. SessionSummary.summarize({
  287. sessionID: ctx.sessionID,
  288. messageID: ctx.assistantMessage.parentID,
  289. })
  290. if (
  291. !ctx.assistantMessage.summary &&
  292. isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
  293. ) {
  294. ctx.needsCompaction = true
  295. }
  296. return
  297. }
  298. case "text-start":
  299. ctx.currentText = {
  300. id: PartID.ascending(),
  301. messageID: ctx.assistantMessage.id,
  302. sessionID: ctx.assistantMessage.sessionID,
  303. type: "text",
  304. text: "",
  305. time: { start: Date.now() },
  306. metadata: value.providerMetadata,
  307. }
  308. yield* session.updatePart(ctx.currentText)
  309. return
  310. case "text-delta":
  311. if (!ctx.currentText) return
  312. ctx.currentText.text += value.text
  313. if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
  314. yield* session.updatePartDelta({
  315. sessionID: ctx.currentText.sessionID,
  316. messageID: ctx.currentText.messageID,
  317. partID: ctx.currentText.id,
  318. field: "text",
  319. delta: value.text,
  320. })
  321. return
  322. case "text-end":
  323. if (!ctx.currentText) return
  324. ctx.currentText.text = ctx.currentText.text.trimEnd()
  325. ctx.currentText.text = (yield* plugin.trigger(
  326. "experimental.text.complete",
  327. {
  328. sessionID: ctx.sessionID,
  329. messageID: ctx.assistantMessage.id,
  330. partID: ctx.currentText.id,
  331. },
  332. { text: ctx.currentText.text },
  333. )).text
  334. {
  335. const end = Date.now()
  336. ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
  337. }
  338. if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
  339. yield* session.updatePart(ctx.currentText)
  340. ctx.currentText = undefined
  341. return
  342. case "finish":
  343. return
  344. default:
  345. log.info("unhandled", { ...value })
  346. return
  347. }
  348. })
  349. const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
  350. if (ctx.snapshot) {
  351. const patch = yield* snapshot.patch(ctx.snapshot)
  352. if (patch.files.length) {
  353. yield* session.updatePart({
  354. id: PartID.ascending(),
  355. messageID: ctx.assistantMessage.id,
  356. sessionID: ctx.sessionID,
  357. type: "patch",
  358. hash: patch.hash,
  359. files: patch.files,
  360. })
  361. }
  362. ctx.snapshot = undefined
  363. }
  364. if (ctx.currentText) {
  365. const end = Date.now()
  366. ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
  367. yield* session.updatePart(ctx.currentText)
  368. ctx.currentText = undefined
  369. }
  370. for (const part of Object.values(ctx.reasoningMap)) {
  371. const end = Date.now()
  372. yield* session.updatePart({
  373. ...part,
  374. time: { start: part.time.start ?? end, end },
  375. })
  376. }
  377. ctx.reasoningMap = {}
  378. for (const part of Object.values(ctx.toolcalls)) {
  379. const end = Date.now()
  380. const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
  381. yield* session.updatePart({
  382. ...part,
  383. state: {
  384. ...part.state,
  385. status: "error",
  386. error: "Tool execution aborted",
  387. metadata: { ...metadata, interrupted: true },
  388. time: { start: "time" in part.state ? part.state.time.start : end, end },
  389. },
  390. })
  391. }
  392. ctx.toolcalls = {}
  393. ctx.assistantMessage.time.completed = Date.now()
  394. yield* session.updateMessage(ctx.assistantMessage)
  395. })
  396. const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
  397. log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
  398. const error = parse(e)
  399. if (MessageV2.ContextOverflowError.isInstance(error)) {
  400. ctx.needsCompaction = true
  401. yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
  402. return
  403. }
  404. ctx.assistantMessage.error = error
  405. yield* bus.publish(Session.Event.Error, {
  406. sessionID: ctx.assistantMessage.sessionID,
  407. error: ctx.assistantMessage.error,
  408. })
  409. yield* status.set(ctx.sessionID, { type: "idle" })
  410. })
  411. const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
  412. log.info("process")
  413. ctx.needsCompaction = false
  414. ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
  415. return yield* Effect.gen(function* () {
  416. yield* Effect.gen(function* () {
  417. ctx.currentText = undefined
  418. ctx.reasoningMap = {}
  419. const stream = llm.stream(streamInput)
  420. yield* stream.pipe(
  421. Stream.tap((event) => handleEvent(event)),
  422. Stream.takeUntil(() => ctx.needsCompaction),
  423. Stream.runDrain,
  424. )
  425. }).pipe(
  426. Effect.onInterrupt(() =>
  427. Effect.gen(function* () {
  428. aborted = true
  429. if (!ctx.assistantMessage.error) {
  430. yield* halt(new DOMException("Aborted", "AbortError"))
  431. }
  432. }),
  433. ),
  434. Effect.catchCauseIf(
  435. (cause) => !Cause.hasInterruptsOnly(cause),
  436. (cause) => Effect.fail(Cause.squash(cause)),
  437. ),
  438. Effect.retry(
  439. SessionRetry.policy({
  440. parse,
  441. set: (info) =>
  442. status.set(ctx.sessionID, {
  443. type: "retry",
  444. attempt: info.attempt,
  445. message: info.message,
  446. next: info.next,
  447. }),
  448. }),
  449. ),
  450. Effect.catch(halt),
  451. Effect.ensuring(cleanup()),
  452. )
  453. if (ctx.needsCompaction) return "compact"
  454. if (ctx.blocked || ctx.assistantMessage.error) return "stop"
  455. return "continue"
  456. })
  457. })
  458. return {
  459. get message() {
  460. return ctx.assistantMessage
  461. },
  462. partFromToolCall(toolCallID: string) {
  463. return ctx.toolcalls[toolCallID]
  464. },
  465. process,
  466. } satisfies Handle
  467. })
  468. return Service.of({ create })
  469. }),
  470. )
  471. export const defaultLayer = Layer.unwrap(
  472. Effect.sync(() =>
  473. layer.pipe(
  474. Layer.provide(Session.defaultLayer),
  475. Layer.provide(Snapshot.defaultLayer),
  476. Layer.provide(Agent.defaultLayer),
  477. Layer.provide(LLM.defaultLayer),
  478. Layer.provide(Permission.defaultLayer),
  479. Layer.provide(Plugin.defaultLayer),
  480. Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
  481. Layer.provide(Bus.layer),
  482. Layer.provide(Config.defaultLayer),
  483. ),
  484. ),
  485. )
  486. }