prompt.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535
  1. import path from "path"
  2. import os from "os"
  3. import fs from "fs/promises"
  4. import z from "zod"
  5. import { Identifier } from "../id/id"
  6. import { MessageV2 } from "./message-v2"
  7. import { Log } from "../util/log"
  8. import { SessionRevert } from "./revert"
  9. import { Session } from "."
  10. import { Agent } from "../agent/agent"
  11. import { Provider } from "../provider/provider"
  12. import {
  13. generateText,
  14. type ModelMessage,
  15. type Tool as AITool,
  16. tool,
  17. wrapLanguageModel,
  18. stepCountIs,
  19. jsonSchema,
  20. } from "ai"
  21. import { SessionCompaction } from "./compaction"
  22. import { Instance } from "../project/instance"
  23. import { Bus } from "../bus"
  24. import { ProviderTransform } from "../provider/transform"
  25. import { SystemPrompt } from "./system"
  26. import { Plugin } from "../plugin"
  27. import PROMPT_PLAN from "../session/prompt/plan.txt"
  28. import BUILD_SWITCH from "../session/prompt/build-switch.txt"
  29. import MAX_STEPS from "../session/prompt/max-steps.txt"
  30. import { defer } from "../util/defer"
  31. import { mergeDeep, pipe } from "remeda"
  32. import { ToolRegistry } from "../tool/registry"
  33. import { Wildcard } from "../util/wildcard"
  34. import { MCP } from "../mcp"
  35. import { LSP } from "../lsp"
  36. import { ReadTool } from "../tool/read"
  37. import { ListTool } from "../tool/ls"
  38. import { FileTime } from "../file/time"
  39. import { ulid } from "ulid"
  40. import { spawn } from "child_process"
  41. import { Command } from "../command"
  42. import { $, fileURLToPath } from "bun"
  43. import { ConfigMarkdown } from "../config/markdown"
  44. import { SessionSummary } from "./summary"
  45. import { Config } from "../config/config"
  46. import { NamedError } from "@opencode-ai/util/error"
  47. import { fn } from "@/util/fn"
  48. import { SessionProcessor } from "./processor"
  49. import { TaskTool } from "@/tool/task"
  50. import { SessionStatus } from "./status"
  51. // @ts-ignore
  52. globalThis.AI_SDK_LOG_WARNINGS = false
  53. export namespace SessionPrompt {
  54. const log = Log.create({ service: "session.prompt" })
  55. export const OUTPUT_TOKEN_MAX = 32_000
  56. const state = Instance.state(
  57. () => {
  58. const data: Record<
  59. string,
  60. {
  61. abort: AbortController
  62. callbacks: {
  63. resolve(input: MessageV2.WithParts): void
  64. reject(): void
  65. }[]
  66. }
  67. > = {}
  68. return data
  69. },
  70. async (current) => {
  71. for (const item of Object.values(current)) {
  72. item.abort.abort()
  73. }
  74. },
  75. )
  76. export function assertNotBusy(sessionID: string) {
  77. const match = state()[sessionID]
  78. if (match) throw new Session.BusyError(sessionID)
  79. }
  80. export const PromptInput = z.object({
  81. sessionID: Identifier.schema("session"),
  82. messageID: Identifier.schema("message").optional(),
  83. model: z
  84. .object({
  85. providerID: z.string(),
  86. modelID: z.string(),
  87. })
  88. .optional(),
  89. agent: z.string().optional(),
  90. noReply: z.boolean().optional(),
  91. system: z.string().optional(),
  92. tools: z.record(z.string(), z.boolean()).optional(),
  93. parts: z.array(
  94. z.discriminatedUnion("type", [
  95. MessageV2.TextPart.omit({
  96. messageID: true,
  97. sessionID: true,
  98. })
  99. .partial({
  100. id: true,
  101. })
  102. .meta({
  103. ref: "TextPartInput",
  104. }),
  105. MessageV2.FilePart.omit({
  106. messageID: true,
  107. sessionID: true,
  108. })
  109. .partial({
  110. id: true,
  111. })
  112. .meta({
  113. ref: "FilePartInput",
  114. }),
  115. MessageV2.AgentPart.omit({
  116. messageID: true,
  117. sessionID: true,
  118. })
  119. .partial({
  120. id: true,
  121. })
  122. .meta({
  123. ref: "AgentPartInput",
  124. }),
  125. MessageV2.SubtaskPart.omit({
  126. messageID: true,
  127. sessionID: true,
  128. })
  129. .partial({
  130. id: true,
  131. })
  132. .meta({
  133. ref: "SubtaskPartInput",
  134. }),
  135. ]),
  136. ),
  137. })
  138. export type PromptInput = z.infer<typeof PromptInput>
  139. export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
  140. const parts: PromptInput["parts"] = [
  141. {
  142. type: "text",
  143. text: template,
  144. },
  145. ]
  146. const files = ConfigMarkdown.files(template)
  147. const seen = new Set<string>()
  148. await Promise.all(
  149. files.map(async (match) => {
  150. const name = match[1]
  151. if (seen.has(name)) return
  152. seen.add(name)
  153. const filepath = name.startsWith("~/")
  154. ? path.join(os.homedir(), name.slice(2))
  155. : path.resolve(Instance.worktree, name)
  156. const stats = await fs.stat(filepath).catch(() => undefined)
  157. if (!stats) {
  158. const agent = await Agent.get(name)
  159. if (agent) {
  160. parts.push({
  161. type: "agent",
  162. name: agent.name,
  163. })
  164. }
  165. return
  166. }
  167. if (stats.isDirectory()) {
  168. parts.push({
  169. type: "file",
  170. url: `file://${filepath}`,
  171. filename: name,
  172. mime: "application/x-directory",
  173. })
  174. return
  175. }
  176. parts.push({
  177. type: "file",
  178. url: `file://${filepath}`,
  179. filename: name,
  180. mime: "text/plain",
  181. })
  182. }),
  183. )
  184. return parts
  185. }
  186. export const prompt = fn(PromptInput, async (input) => {
  187. const session = await Session.get(input.sessionID)
  188. await SessionRevert.cleanup(session)
  189. const message = await createUserMessage(input)
  190. await Session.touch(input.sessionID)
  191. if (input.noReply === true) {
  192. return message
  193. }
  194. return loop(input.sessionID)
  195. })
  196. function start(sessionID: string) {
  197. const s = state()
  198. if (s[sessionID]) return
  199. const controller = new AbortController()
  200. s[sessionID] = {
  201. abort: controller,
  202. callbacks: [],
  203. }
  204. return controller.signal
  205. }
  206. export function cancel(sessionID: string) {
  207. log.info("cancel", { sessionID })
  208. const s = state()
  209. const match = s[sessionID]
  210. if (!match) return
  211. match.abort.abort()
  212. for (const item of match.callbacks) {
  213. item.reject()
  214. }
  215. delete s[sessionID]
  216. SessionStatus.set(sessionID, { type: "idle" })
  217. return
  218. }
  219. export const loop = fn(Identifier.schema("session"), async (sessionID) => {
  220. const abort = start(sessionID)
  221. if (!abort) {
  222. return new Promise<MessageV2.WithParts>((resolve, reject) => {
  223. const callbacks = state()[sessionID].callbacks
  224. callbacks.push({ resolve, reject })
  225. })
  226. }
  227. using _ = defer(() => cancel(sessionID))
  228. let step = 0
  229. while (true) {
  230. SessionStatus.set(sessionID, { type: "busy" })
  231. log.info("loop", { step, sessionID })
  232. if (abort.aborted) break
  233. let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
  234. let lastUser: MessageV2.User | undefined
  235. let lastAssistant: MessageV2.Assistant | undefined
  236. let lastFinished: MessageV2.Assistant | undefined
  237. let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
  238. for (let i = msgs.length - 1; i >= 0; i--) {
  239. const msg = msgs[i]
  240. if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
  241. if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
  242. if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
  243. lastFinished = msg.info as MessageV2.Assistant
  244. if (lastUser && lastFinished) break
  245. const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
  246. if (task && !lastFinished) {
  247. tasks.push(...task)
  248. }
  249. }
  250. if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
  251. if (
  252. lastAssistant?.finish &&
  253. !["tool-calls", "unknown"].includes(lastAssistant.finish) &&
  254. lastUser.id < lastAssistant.id
  255. ) {
  256. log.info("exiting loop", { sessionID })
  257. break
  258. }
  259. step++
  260. if (step === 1)
  261. ensureTitle({
  262. session: await Session.get(sessionID),
  263. modelID: lastUser.model.modelID,
  264. providerID: lastUser.model.providerID,
  265. message: msgs.find((m) => m.info.role === "user")!,
  266. history: msgs,
  267. })
  268. const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
  269. const language = await Provider.getLanguage(model)
  270. const task = tasks.pop()
  271. // pending subtask
  272. // TODO: centralize "invoke tool" logic
  273. if (task?.type === "subtask") {
  274. const taskTool = await TaskTool.init()
  275. const assistantMessage = (await Session.updateMessage({
  276. id: Identifier.ascending("message"),
  277. role: "assistant",
  278. parentID: lastUser.id,
  279. sessionID,
  280. mode: task.agent,
  281. path: {
  282. cwd: Instance.directory,
  283. root: Instance.worktree,
  284. },
  285. cost: 0,
  286. tokens: {
  287. input: 0,
  288. output: 0,
  289. reasoning: 0,
  290. cache: { read: 0, write: 0 },
  291. },
  292. modelID: model.id,
  293. providerID: model.providerID,
  294. time: {
  295. created: Date.now(),
  296. },
  297. })) as MessageV2.Assistant
  298. let part = (await Session.updatePart({
  299. id: Identifier.ascending("part"),
  300. messageID: assistantMessage.id,
  301. sessionID: assistantMessage.sessionID,
  302. type: "tool",
  303. callID: ulid(),
  304. tool: TaskTool.id,
  305. state: {
  306. status: "running",
  307. input: {
  308. prompt: task.prompt,
  309. description: task.description,
  310. subagent_type: task.agent,
  311. },
  312. time: {
  313. start: Date.now(),
  314. },
  315. },
  316. })) as MessageV2.ToolPart
  317. const result = await taskTool
  318. .execute(
  319. {
  320. prompt: task.prompt,
  321. description: task.description,
  322. subagent_type: task.agent,
  323. },
  324. {
  325. agent: task.agent,
  326. messageID: assistantMessage.id,
  327. sessionID: sessionID,
  328. abort,
  329. async metadata(input) {
  330. await Session.updatePart({
  331. ...part,
  332. type: "tool",
  333. state: {
  334. ...part.state,
  335. ...input,
  336. },
  337. } satisfies MessageV2.ToolPart)
  338. },
  339. },
  340. )
  341. .catch(() => {})
  342. assistantMessage.finish = "tool-calls"
  343. assistantMessage.time.completed = Date.now()
  344. await Session.updateMessage(assistantMessage)
  345. if (result && part.state.status === "running") {
  346. await Session.updatePart({
  347. ...part,
  348. state: {
  349. status: "completed",
  350. input: part.state.input,
  351. title: result.title,
  352. metadata: result.metadata,
  353. output: result.output,
  354. attachments: result.attachments,
  355. time: {
  356. ...part.state.time,
  357. end: Date.now(),
  358. },
  359. },
  360. } satisfies MessageV2.ToolPart)
  361. }
  362. if (!result) {
  363. await Session.updatePart({
  364. ...part,
  365. state: {
  366. status: "error",
  367. error: "Tool execution failed",
  368. time: {
  369. start: part.state.status === "running" ? part.state.time.start : Date.now(),
  370. end: Date.now(),
  371. },
  372. metadata: part.metadata,
  373. input: part.state.input,
  374. },
  375. } satisfies MessageV2.ToolPart)
  376. }
  377. continue
  378. }
  379. // pending compaction
  380. if (task?.type === "compaction") {
  381. const result = await SessionCompaction.process({
  382. messages: msgs,
  383. parentID: lastUser.id,
  384. abort,
  385. agent: lastUser.agent,
  386. model: {
  387. providerID: model.providerID,
  388. modelID: model.id,
  389. },
  390. sessionID,
  391. auto: task.auto,
  392. })
  393. if (result === "stop") break
  394. continue
  395. }
  396. // context overflow, needs compaction
  397. if (
  398. lastFinished &&
  399. lastFinished.summary !== true &&
  400. SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })
  401. ) {
  402. await SessionCompaction.create({
  403. sessionID,
  404. agent: lastUser.agent,
  405. model: lastUser.model,
  406. auto: true,
  407. })
  408. continue
  409. }
  410. // normal processing
  411. const cfg = await Config.get()
  412. const agent = await Agent.get(lastUser.agent)
  413. const maxSteps = agent.maxSteps ?? Infinity
  414. const isLastStep = step >= maxSteps
  415. msgs = insertReminders({
  416. messages: msgs,
  417. agent,
  418. })
  419. const processor = SessionProcessor.create({
  420. assistantMessage: (await Session.updateMessage({
  421. id: Identifier.ascending("message"),
  422. parentID: lastUser.id,
  423. role: "assistant",
  424. mode: agent.name,
  425. path: {
  426. cwd: Instance.directory,
  427. root: Instance.worktree,
  428. },
  429. cost: 0,
  430. tokens: {
  431. input: 0,
  432. output: 0,
  433. reasoning: 0,
  434. cache: { read: 0, write: 0 },
  435. },
  436. modelID: model.id,
  437. providerID: model.providerID,
  438. time: {
  439. created: Date.now(),
  440. },
  441. sessionID,
  442. })) as MessageV2.Assistant,
  443. sessionID: sessionID,
  444. model,
  445. abort,
  446. })
  447. const system = await resolveSystemPrompt({
  448. model,
  449. agent,
  450. system: lastUser.system,
  451. isLastStep,
  452. })
  453. const tools = await resolveTools({
  454. agent,
  455. sessionID,
  456. model,
  457. tools: lastUser.tools,
  458. processor,
  459. })
  460. const provider = await Provider.getProvider(model.providerID)
  461. const params = await Plugin.trigger(
  462. "chat.params",
  463. {
  464. sessionID: sessionID,
  465. agent: lastUser.agent,
  466. model: model,
  467. provider,
  468. message: lastUser,
  469. },
  470. {
  471. temperature: model.capabilities.temperature
  472. ? (agent.temperature ?? ProviderTransform.temperature(model))
  473. : undefined,
  474. topP: agent.topP ?? ProviderTransform.topP(model),
  475. options: pipe(
  476. {},
  477. mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)),
  478. mergeDeep(model.options),
  479. mergeDeep(agent.options),
  480. ),
  481. },
  482. )
  483. if (step === 1) {
  484. SessionSummary.summarize({
  485. sessionID: sessionID,
  486. messageID: lastUser.id,
  487. })
  488. }
  489. const messages = [
  490. ...system.map(
  491. (x): ModelMessage => ({
  492. role: "system",
  493. content: x,
  494. }),
  495. ),
  496. ...MessageV2.toModelMessage(
  497. msgs.filter((m) => {
  498. if (m.info.role !== "assistant" || m.info.error === undefined) {
  499. return true
  500. }
  501. if (
  502. MessageV2.AbortedError.isInstance(m.info.error) &&
  503. m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
  504. ) {
  505. return true
  506. }
  507. return false
  508. }),
  509. ),
  510. ...(isLastStep
  511. ? [
  512. {
  513. role: "assistant" as const,
  514. content: MAX_STEPS,
  515. },
  516. ]
  517. : []),
  518. ]
  519. const result = await processor.process({
  520. onError(error) {
  521. log.error("stream error", {
  522. error,
  523. })
  524. },
  525. async experimental_repairToolCall(input) {
  526. const lower = input.toolCall.toolName.toLowerCase()
  527. if (lower !== input.toolCall.toolName && tools[lower]) {
  528. log.info("repairing tool call", {
  529. tool: input.toolCall.toolName,
  530. repaired: lower,
  531. })
  532. return {
  533. ...input.toolCall,
  534. toolName: lower,
  535. }
  536. }
  537. return {
  538. ...input.toolCall,
  539. input: JSON.stringify({
  540. tool: input.toolCall.toolName,
  541. error: input.error.message,
  542. }),
  543. toolName: "invalid",
  544. }
  545. },
  546. headers: {
  547. ...(model.providerID.startsWith("opencode")
  548. ? {
  549. "x-opencode-project": Instance.project.id,
  550. "x-opencode-session": sessionID,
  551. "x-opencode-request": lastUser.id,
  552. }
  553. : undefined),
  554. ...model.headers,
  555. },
  556. // set to 0, we handle loop
  557. maxRetries: 0,
  558. activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
  559. maxOutputTokens: ProviderTransform.maxOutputTokens(
  560. model.api.npm,
  561. params.options,
  562. model.limit.output,
  563. OUTPUT_TOKEN_MAX,
  564. ),
  565. abortSignal: abort,
  566. providerOptions: ProviderTransform.providerOptions(model, params.options, messages),
  567. stopWhen: stepCountIs(1),
  568. temperature: params.temperature,
  569. topP: params.topP,
  570. toolChoice: isLastStep ? "none" : undefined,
  571. messages,
  572. tools: model.capabilities.toolcall === false ? undefined : tools,
  573. model: wrapLanguageModel({
  574. model: language,
  575. middleware: [
  576. {
  577. async transformParams(args) {
  578. if (args.type === "stream") {
  579. // @ts-expect-error - prompt types are compatible at runtime
  580. args.params.prompt = ProviderTransform.message(args.params.prompt, model)
  581. }
  582. // Transform tool schemas for provider compatibility
  583. if (args.params.tools && Array.isArray(args.params.tools)) {
  584. args.params.tools = args.params.tools.map((tool: any) => {
  585. // Tools at middleware level have inputSchema, not parameters
  586. if (tool.inputSchema && typeof tool.inputSchema === "object") {
  587. // Transform the inputSchema for provider compatibility
  588. return {
  589. ...tool,
  590. inputSchema: ProviderTransform.schema(model, tool.inputSchema),
  591. }
  592. }
  593. // If no inputSchema, return tool unchanged
  594. return tool
  595. })
  596. }
  597. return args.params
  598. },
  599. },
  600. ],
  601. }),
  602. experimental_telemetry: {
  603. isEnabled: cfg.experimental?.openTelemetry,
  604. metadata: {
  605. userId: cfg.username ?? "unknown",
  606. sessionId: sessionID,
  607. },
  608. },
  609. })
  610. if (result === "stop") break
  611. continue
  612. }
  613. SessionCompaction.prune({ sessionID })
  614. for await (const item of MessageV2.stream(sessionID)) {
  615. if (item.info.role === "user") continue
  616. const queued = state()[sessionID]?.callbacks ?? []
  617. for (const q of queued) {
  618. q.resolve(item)
  619. }
  620. return item
  621. }
  622. throw new Error("Impossible")
  623. })
  624. async function lastModel(sessionID: string) {
  625. for await (const item of MessageV2.stream(sessionID)) {
  626. if (item.info.role === "user" && item.info.model) return item.info.model
  627. }
  628. return Provider.defaultModel()
  629. }
  630. async function resolveSystemPrompt(input: {
  631. system?: string
  632. agent: Agent.Info
  633. model: Provider.Model
  634. isLastStep?: boolean
  635. }) {
  636. let system = SystemPrompt.header(input.model.providerID)
  637. system.push(
  638. ...(() => {
  639. if (input.system) return [input.system]
  640. if (input.agent.prompt) return [input.agent.prompt]
  641. return SystemPrompt.provider(input.model)
  642. })(),
  643. )
  644. system.push(...(await SystemPrompt.environment()))
  645. system.push(...(await SystemPrompt.custom()))
  646. if (input.isLastStep) {
  647. system.push(MAX_STEPS)
  648. }
  649. // max 2 system prompt messages for caching purposes
  650. const [first, ...rest] = system
  651. system = [first, rest.join("\n")]
  652. return system
  653. }
  654. async function resolveTools(input: {
  655. agent: Agent.Info
  656. model: Provider.Model
  657. sessionID: string
  658. tools?: Record<string, boolean>
  659. processor: SessionProcessor.Info
  660. }) {
  661. const tools: Record<string, AITool> = {}
  662. const enabledTools = pipe(
  663. input.agent.tools,
  664. mergeDeep(await ToolRegistry.enabled(input.agent)),
  665. mergeDeep(input.tools ?? {}),
  666. )
  667. for (const item of await ToolRegistry.tools(input.model.providerID)) {
  668. if (Wildcard.all(item.id, enabledTools) === false) continue
  669. const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
  670. tools[item.id] = tool({
  671. id: item.id as any,
  672. description: item.description,
  673. inputSchema: jsonSchema(schema as any),
  674. async execute(args, options) {
  675. await Plugin.trigger(
  676. "tool.execute.before",
  677. {
  678. tool: item.id,
  679. sessionID: input.sessionID,
  680. callID: options.toolCallId,
  681. },
  682. {
  683. args,
  684. },
  685. )
  686. const result = await item.execute(args, {
  687. sessionID: input.sessionID,
  688. abort: options.abortSignal!,
  689. messageID: input.processor.message.id,
  690. callID: options.toolCallId,
  691. extra: { model: input.model },
  692. agent: input.agent.name,
  693. metadata: async (val) => {
  694. const match = input.processor.partFromToolCall(options.toolCallId)
  695. if (match && match.state.status === "running") {
  696. await Session.updatePart({
  697. ...match,
  698. state: {
  699. title: val.title,
  700. metadata: val.metadata,
  701. status: "running",
  702. input: args,
  703. time: {
  704. start: Date.now(),
  705. },
  706. },
  707. })
  708. }
  709. },
  710. })
  711. await Plugin.trigger(
  712. "tool.execute.after",
  713. {
  714. tool: item.id,
  715. sessionID: input.sessionID,
  716. callID: options.toolCallId,
  717. },
  718. result,
  719. )
  720. return result
  721. },
  722. toModelOutput(result) {
  723. return {
  724. type: "text",
  725. value: result.output,
  726. }
  727. },
  728. })
  729. }
  730. for (const [key, item] of Object.entries(await MCP.tools())) {
  731. if (Wildcard.all(key, enabledTools) === false) continue
  732. const execute = item.execute
  733. if (!execute) continue
  734. // Wrap execute to add plugin hooks and format output
  735. item.execute = async (args, opts) => {
  736. await Plugin.trigger(
  737. "tool.execute.before",
  738. {
  739. tool: key,
  740. sessionID: input.sessionID,
  741. callID: opts.toolCallId,
  742. },
  743. {
  744. args,
  745. },
  746. )
  747. const result = await execute(args, opts)
  748. await Plugin.trigger(
  749. "tool.execute.after",
  750. {
  751. tool: key,
  752. sessionID: input.sessionID,
  753. callID: opts.toolCallId,
  754. },
  755. result,
  756. )
  757. const textParts: string[] = []
  758. const attachments: MessageV2.FilePart[] = []
  759. for (const contentItem of result.content) {
  760. if (contentItem.type === "text") {
  761. textParts.push(contentItem.text)
  762. } else if (contentItem.type === "image") {
  763. attachments.push({
  764. id: Identifier.ascending("part"),
  765. sessionID: input.sessionID,
  766. messageID: input.processor.message.id,
  767. type: "file",
  768. mime: contentItem.mimeType,
  769. url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
  770. })
  771. }
  772. // Add support for other types if needed
  773. }
  774. return {
  775. title: "",
  776. metadata: result.metadata ?? {},
  777. output: textParts.join("\n\n"),
  778. attachments,
  779. content: result.content, // directly return content to preserve ordering when outputting to model
  780. }
  781. }
  782. item.toModelOutput = (result) => {
  783. return {
  784. type: "text",
  785. value: result.output,
  786. }
  787. }
  788. tools[key] = item
  789. }
  790. return tools
  791. }
  792. async function createUserMessage(input: PromptInput) {
  793. const agent = await Agent.get(input.agent ?? "build")
  794. const info: MessageV2.Info = {
  795. id: input.messageID ?? Identifier.ascending("message"),
  796. role: "user",
  797. sessionID: input.sessionID,
  798. time: {
  799. created: Date.now(),
  800. },
  801. tools: input.tools,
  802. system: input.system,
  803. agent: agent.name,
  804. model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
  805. }
  806. const parts = await Promise.all(
  807. input.parts.map(async (part): Promise<MessageV2.Part[]> => {
  808. if (part.type === "file") {
  809. const url = new URL(part.url)
  810. switch (url.protocol) {
  811. case "data:":
  812. if (part.mime === "text/plain") {
  813. return [
  814. {
  815. id: Identifier.ascending("part"),
  816. messageID: info.id,
  817. sessionID: input.sessionID,
  818. type: "text",
  819. synthetic: true,
  820. text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
  821. },
  822. {
  823. id: Identifier.ascending("part"),
  824. messageID: info.id,
  825. sessionID: input.sessionID,
  826. type: "text",
  827. synthetic: true,
  828. text: Buffer.from(part.url, "base64url").toString(),
  829. },
  830. {
  831. ...part,
  832. id: part.id ?? Identifier.ascending("part"),
  833. messageID: info.id,
  834. sessionID: input.sessionID,
  835. },
  836. ]
  837. }
  838. break
  839. case "file:":
  840. log.info("file", { mime: part.mime })
  841. // have to normalize, symbol search returns absolute paths
  842. // Decode the pathname since URL constructor doesn't automatically decode it
  843. const filepath = fileURLToPath(part.url)
  844. const stat = await Bun.file(filepath).stat()
  845. if (stat.isDirectory()) {
  846. part.mime = "application/x-directory"
  847. }
  848. if (part.mime === "text/plain") {
  849. let offset: number | undefined = undefined
  850. let limit: number | undefined = undefined
  851. const range = {
  852. start: url.searchParams.get("start"),
  853. end: url.searchParams.get("end"),
  854. }
  855. if (range.start != null) {
  856. const filePathURI = part.url.split("?")[0]
  857. let start = parseInt(range.start)
  858. let end = range.end ? parseInt(range.end) : undefined
  859. // some LSP servers (eg, gopls) don't give full range in
  860. // workspace/symbol searches, so we'll try to find the
  861. // symbol in the document to get the full range
  862. if (start === end) {
  863. const symbols = await LSP.documentSymbol(filePathURI)
  864. for (const symbol of symbols) {
  865. let range: LSP.Range | undefined
  866. if ("range" in symbol) {
  867. range = symbol.range
  868. } else if ("location" in symbol) {
  869. range = symbol.location.range
  870. }
  871. if (range?.start?.line && range?.start?.line === start) {
  872. start = range.start.line
  873. end = range?.end?.line ?? start
  874. break
  875. }
  876. }
  877. }
  878. offset = Math.max(start - 1, 0)
  879. if (end) {
  880. limit = end - offset
  881. }
  882. }
  883. const args = { filePath: filepath, offset, limit }
  884. const pieces: MessageV2.Part[] = [
  885. {
  886. id: Identifier.ascending("part"),
  887. messageID: info.id,
  888. sessionID: input.sessionID,
  889. type: "text",
  890. synthetic: true,
  891. text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
  892. },
  893. ]
  894. await ReadTool.init()
  895. .then(async (t) => {
  896. const model = await Provider.getModel(info.model.providerID, info.model.modelID)
  897. const result = await t.execute(args, {
  898. sessionID: input.sessionID,
  899. abort: new AbortController().signal,
  900. agent: input.agent!,
  901. messageID: info.id,
  902. extra: { bypassCwdCheck: true, model },
  903. metadata: async () => {},
  904. })
  905. pieces.push({
  906. id: Identifier.ascending("part"),
  907. messageID: info.id,
  908. sessionID: input.sessionID,
  909. type: "text",
  910. synthetic: true,
  911. text: result.output,
  912. })
  913. if (result.attachments?.length) {
  914. pieces.push(
  915. ...result.attachments.map((attachment) => ({
  916. ...attachment,
  917. synthetic: true,
  918. filename: attachment.filename ?? part.filename,
  919. messageID: info.id,
  920. sessionID: input.sessionID,
  921. })),
  922. )
  923. } else {
  924. pieces.push({
  925. ...part,
  926. id: part.id ?? Identifier.ascending("part"),
  927. messageID: info.id,
  928. sessionID: input.sessionID,
  929. })
  930. }
  931. })
  932. .catch((error) => {
  933. log.error("failed to read file", { error })
  934. const message = error instanceof Error ? error.message : error.toString()
  935. Bus.publish(Session.Event.Error, {
  936. sessionID: input.sessionID,
  937. error: new NamedError.Unknown({
  938. message,
  939. }).toObject(),
  940. })
  941. pieces.push({
  942. id: Identifier.ascending("part"),
  943. messageID: info.id,
  944. sessionID: input.sessionID,
  945. type: "text",
  946. synthetic: true,
  947. text: `Read tool failed to read ${filepath} with the following error: ${message}`,
  948. })
  949. })
  950. return pieces
  951. }
  952. if (part.mime === "application/x-directory") {
  953. const args = { path: filepath }
  954. const result = await ListTool.init().then((t) =>
  955. t.execute(args, {
  956. sessionID: input.sessionID,
  957. abort: new AbortController().signal,
  958. agent: input.agent!,
  959. messageID: info.id,
  960. extra: { bypassCwdCheck: true },
  961. metadata: async () => {},
  962. }),
  963. )
  964. return [
  965. {
  966. id: Identifier.ascending("part"),
  967. messageID: info.id,
  968. sessionID: input.sessionID,
  969. type: "text",
  970. synthetic: true,
  971. text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
  972. },
  973. {
  974. id: Identifier.ascending("part"),
  975. messageID: info.id,
  976. sessionID: input.sessionID,
  977. type: "text",
  978. synthetic: true,
  979. text: result.output,
  980. },
  981. {
  982. ...part,
  983. id: part.id ?? Identifier.ascending("part"),
  984. messageID: info.id,
  985. sessionID: input.sessionID,
  986. },
  987. ]
  988. }
  989. const file = Bun.file(filepath)
  990. FileTime.read(input.sessionID, filepath)
  991. return [
  992. {
  993. id: Identifier.ascending("part"),
  994. messageID: info.id,
  995. sessionID: input.sessionID,
  996. type: "text",
  997. text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
  998. synthetic: true,
  999. },
  1000. {
  1001. id: part.id ?? Identifier.ascending("part"),
  1002. messageID: info.id,
  1003. sessionID: input.sessionID,
  1004. type: "file",
  1005. url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
  1006. mime: part.mime,
  1007. filename: part.filename!,
  1008. source: part.source,
  1009. },
  1010. ]
  1011. }
  1012. }
  1013. if (part.type === "agent") {
  1014. return [
  1015. {
  1016. id: Identifier.ascending("part"),
  1017. ...part,
  1018. messageID: info.id,
  1019. sessionID: input.sessionID,
  1020. },
  1021. {
  1022. id: Identifier.ascending("part"),
  1023. messageID: info.id,
  1024. sessionID: input.sessionID,
  1025. type: "text",
  1026. synthetic: true,
  1027. text:
  1028. "Use the above message and context to generate a prompt and call the task tool with subagent: " +
  1029. part.name,
  1030. },
  1031. ]
  1032. }
  1033. return [
  1034. {
  1035. id: Identifier.ascending("part"),
  1036. ...part,
  1037. messageID: info.id,
  1038. sessionID: input.sessionID,
  1039. },
  1040. ]
  1041. }),
  1042. ).then((x) => x.flat())
  1043. await Plugin.trigger(
  1044. "chat.message",
  1045. {
  1046. sessionID: input.sessionID,
  1047. agent: input.agent,
  1048. model: input.model,
  1049. messageID: input.messageID,
  1050. },
  1051. {
  1052. message: info,
  1053. parts,
  1054. },
  1055. )
  1056. await Session.updateMessage(info)
  1057. for (const part of parts) {
  1058. await Session.updatePart(part)
  1059. }
  1060. return {
  1061. info,
  1062. parts,
  1063. }
  1064. }
  1065. function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
  1066. const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
  1067. if (!userMessage) return input.messages
  1068. if (input.agent.name === "plan") {
  1069. userMessage.parts.push({
  1070. id: Identifier.ascending("part"),
  1071. messageID: userMessage.info.id,
  1072. sessionID: userMessage.info.sessionID,
  1073. type: "text",
  1074. // TODO (for mr dax): update to use the anthropic full fledged one (see plan-reminder-anthropic.txt)
  1075. text: PROMPT_PLAN,
  1076. synthetic: true,
  1077. })
  1078. }
  1079. const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan")
  1080. if (wasPlan && input.agent.name === "build") {
  1081. userMessage.parts.push({
  1082. id: Identifier.ascending("part"),
  1083. messageID: userMessage.info.id,
  1084. sessionID: userMessage.info.sessionID,
  1085. type: "text",
  1086. text: BUILD_SWITCH,
  1087. synthetic: true,
  1088. })
  1089. }
  1090. return input.messages
  1091. }
  1092. export const ShellInput = z.object({
  1093. sessionID: Identifier.schema("session"),
  1094. agent: z.string(),
  1095. model: z
  1096. .object({
  1097. providerID: z.string(),
  1098. modelID: z.string(),
  1099. })
  1100. .optional(),
  1101. command: z.string(),
  1102. })
  1103. export type ShellInput = z.infer<typeof ShellInput>
  1104. export async function shell(input: ShellInput) {
  1105. const session = await Session.get(input.sessionID)
  1106. if (session.revert) {
  1107. SessionRevert.cleanup(session)
  1108. }
  1109. const agent = await Agent.get(input.agent)
  1110. const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
  1111. const userMsg: MessageV2.User = {
  1112. id: Identifier.ascending("message"),
  1113. sessionID: input.sessionID,
  1114. time: {
  1115. created: Date.now(),
  1116. },
  1117. role: "user",
  1118. agent: input.agent,
  1119. model: {
  1120. providerID: model.providerID,
  1121. modelID: model.modelID,
  1122. },
  1123. }
  1124. await Session.updateMessage(userMsg)
  1125. const userPart: MessageV2.Part = {
  1126. type: "text",
  1127. id: Identifier.ascending("part"),
  1128. messageID: userMsg.id,
  1129. sessionID: input.sessionID,
  1130. text: "The following tool was executed by the user",
  1131. synthetic: true,
  1132. }
  1133. await Session.updatePart(userPart)
  1134. const msg: MessageV2.Assistant = {
  1135. id: Identifier.ascending("message"),
  1136. sessionID: input.sessionID,
  1137. parentID: userMsg.id,
  1138. mode: input.agent,
  1139. cost: 0,
  1140. path: {
  1141. cwd: Instance.directory,
  1142. root: Instance.worktree,
  1143. },
  1144. time: {
  1145. created: Date.now(),
  1146. },
  1147. role: "assistant",
  1148. tokens: {
  1149. input: 0,
  1150. output: 0,
  1151. reasoning: 0,
  1152. cache: { read: 0, write: 0 },
  1153. },
  1154. modelID: model.modelID,
  1155. providerID: model.providerID,
  1156. }
  1157. await Session.updateMessage(msg)
  1158. const part: MessageV2.Part = {
  1159. type: "tool",
  1160. id: Identifier.ascending("part"),
  1161. messageID: msg.id,
  1162. sessionID: input.sessionID,
  1163. tool: "bash",
  1164. callID: ulid(),
  1165. state: {
  1166. status: "running",
  1167. time: {
  1168. start: Date.now(),
  1169. },
  1170. input: {
  1171. command: input.command,
  1172. },
  1173. },
  1174. }
  1175. await Session.updatePart(part)
  1176. const shell = process.env["SHELL"] ?? (process.platform === "win32" ? process.env["COMSPEC"] || "cmd.exe" : "bash")
  1177. const shellName = path.basename(shell).toLowerCase()
  1178. const invocations: Record<string, { args: string[] }> = {
  1179. nu: {
  1180. args: ["-c", input.command],
  1181. },
  1182. fish: {
  1183. args: ["-c", input.command],
  1184. },
  1185. zsh: {
  1186. args: [
  1187. "-c",
  1188. "-l",
  1189. `
  1190. [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
  1191. [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
  1192. ${input.command}
  1193. `,
  1194. ],
  1195. },
  1196. bash: {
  1197. args: [
  1198. "-c",
  1199. "-l",
  1200. `
  1201. [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
  1202. ${input.command}
  1203. `,
  1204. ],
  1205. },
  1206. // Windows cmd.exe
  1207. "cmd.exe": {
  1208. args: ["/c", input.command],
  1209. },
  1210. // Windows PowerShell
  1211. "powershell.exe": {
  1212. args: ["-NoProfile", "-Command", input.command],
  1213. },
  1214. // Fallback: any shell that doesn't match those above
  1215. "": {
  1216. args: ["-c", "-l", `${input.command}`],
  1217. },
  1218. }
  1219. const matchingInvocation = invocations[shellName] ?? invocations[""]
  1220. const args = matchingInvocation?.args
  1221. const proc = spawn(shell, args, {
  1222. cwd: Instance.directory,
  1223. detached: process.platform !== "win32",
  1224. stdio: ["ignore", "pipe", "pipe"],
  1225. env: {
  1226. ...process.env,
  1227. TERM: "dumb",
  1228. },
  1229. })
  1230. let output = ""
  1231. proc.stdout?.on("data", (chunk) => {
  1232. output += chunk.toString()
  1233. if (part.state.status === "running") {
  1234. part.state.metadata = {
  1235. output: output,
  1236. description: "",
  1237. }
  1238. Session.updatePart(part)
  1239. }
  1240. })
  1241. proc.stderr?.on("data", (chunk) => {
  1242. output += chunk.toString()
  1243. if (part.state.status === "running") {
  1244. part.state.metadata = {
  1245. output: output,
  1246. description: "",
  1247. }
  1248. Session.updatePart(part)
  1249. }
  1250. })
  1251. await new Promise<void>((resolve) => {
  1252. proc.on("close", () => {
  1253. resolve()
  1254. })
  1255. })
  1256. msg.time.completed = Date.now()
  1257. await Session.updateMessage(msg)
  1258. if (part.state.status === "running") {
  1259. part.state = {
  1260. status: "completed",
  1261. time: {
  1262. ...part.state.time,
  1263. end: Date.now(),
  1264. },
  1265. input: part.state.input,
  1266. title: "",
  1267. metadata: {
  1268. output,
  1269. description: "",
  1270. },
  1271. output,
  1272. }
  1273. await Session.updatePart(part)
  1274. }
  1275. return { info: msg, parts: [part] }
  1276. }
  1277. export const CommandInput = z.object({
  1278. messageID: Identifier.schema("message").optional(),
  1279. sessionID: Identifier.schema("session"),
  1280. agent: z.string().optional(),
  1281. model: z.string().optional(),
  1282. arguments: z.string(),
  1283. command: z.string(),
  1284. })
  1285. export type CommandInput = z.infer<typeof CommandInput>
  1286. const bashRegex = /!`([^`]+)`/g
  1287. const argsRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
  1288. const placeholderRegex = /\$(\d+)/g
  1289. const quoteTrimRegex = /^["']|["']$/g
  1290. /**
  1291. * Regular expression to match @ file references in text
  1292. * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
  1293. * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
  1294. */
  1295. export async function command(input: CommandInput) {
  1296. log.info("command", input)
  1297. const command = await Command.get(input.command)
  1298. const agentName = command.agent ?? input.agent ?? "build"
  1299. const raw = input.arguments.match(argsRegex) ?? []
  1300. const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
  1301. const placeholders = command.template.match(placeholderRegex) ?? []
  1302. let last = 0
  1303. for (const item of placeholders) {
  1304. const value = Number(item.slice(1))
  1305. if (value > last) last = value
  1306. }
  1307. // Let the final placeholder swallow any extra arguments so prompts read naturally
  1308. const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
  1309. const position = Number(index)
  1310. const argIndex = position - 1
  1311. if (argIndex >= args.length) return ""
  1312. if (position === last) return args.slice(argIndex).join(" ")
  1313. return args[argIndex]
  1314. })
  1315. let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
  1316. const shell = ConfigMarkdown.shell(template)
  1317. if (shell.length > 0) {
  1318. const results = await Promise.all(
  1319. shell.map(async ([, cmd]) => {
  1320. try {
  1321. return await $`${{ raw: cmd }}`.nothrow().text()
  1322. } catch (error) {
  1323. return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
  1324. }
  1325. }),
  1326. )
  1327. let index = 0
  1328. template = template.replace(bashRegex, () => results[index++])
  1329. }
  1330. template = template.trim()
  1331. const model = await (async () => {
  1332. if (command.model) {
  1333. return Provider.parseModel(command.model)
  1334. }
  1335. if (command.agent) {
  1336. const cmdAgent = await Agent.get(command.agent)
  1337. if (cmdAgent.model) {
  1338. return cmdAgent.model
  1339. }
  1340. }
  1341. if (input.model) return Provider.parseModel(input.model)
  1342. return await lastModel(input.sessionID)
  1343. })()
  1344. const agent = await Agent.get(agentName)
  1345. const parts =
  1346. (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
  1347. ? [
  1348. {
  1349. type: "subtask" as const,
  1350. agent: agent.name,
  1351. description: command.description ?? "",
  1352. // TODO: how can we make task tool accept a more complex input?
  1353. prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
  1354. },
  1355. ]
  1356. : await resolvePromptParts(template)
  1357. const result = (await prompt({
  1358. sessionID: input.sessionID,
  1359. messageID: input.messageID,
  1360. model,
  1361. agent: agentName,
  1362. parts,
  1363. })) as MessageV2.WithParts
  1364. Bus.publish(Command.Event.Executed, {
  1365. name: input.command,
  1366. sessionID: input.sessionID,
  1367. arguments: input.arguments,
  1368. messageID: result.info.id,
  1369. })
  1370. return result
  1371. }
  1372. async function ensureTitle(input: {
  1373. session: Session.Info
  1374. message: MessageV2.WithParts
  1375. history: MessageV2.WithParts[]
  1376. providerID: string
  1377. modelID: string
  1378. }) {
  1379. if (input.session.parentID) return
  1380. if (!Session.isDefaultTitle(input.session.title)) return
  1381. const isFirst =
  1382. input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
  1383. .length === 1
  1384. if (!isFirst) return
  1385. const cfg = await Config.get()
  1386. const small =
  1387. (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
  1388. const language = await Provider.getLanguage(small)
  1389. const provider = await Provider.getProvider(small.providerID)
  1390. const options = pipe(
  1391. {},
  1392. mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)),
  1393. mergeDeep(ProviderTransform.smallOptions(small)),
  1394. mergeDeep(small.options),
  1395. )
  1396. await generateText({
  1397. // use higher # for reasoning models since reasoning tokens eat up a lot of the budget
  1398. maxOutputTokens: small.capabilities.reasoning ? 3000 : 20,
  1399. providerOptions: ProviderTransform.providerOptions(small, options, []),
  1400. messages: [
  1401. ...SystemPrompt.title(small.providerID).map(
  1402. (x): ModelMessage => ({
  1403. role: "system",
  1404. content: x,
  1405. }),
  1406. ),
  1407. {
  1408. role: "user",
  1409. content: "Generate a title for this conversation:\n",
  1410. },
  1411. ...MessageV2.toModelMessage([
  1412. {
  1413. info: {
  1414. id: Identifier.ascending("message"),
  1415. role: "user",
  1416. sessionID: input.session.id,
  1417. time: {
  1418. created: Date.now(),
  1419. },
  1420. agent: input.message.info.role === "user" ? input.message.info.agent : "build",
  1421. model: {
  1422. providerID: input.providerID,
  1423. modelID: input.modelID,
  1424. },
  1425. },
  1426. parts: input.message.parts,
  1427. },
  1428. ]),
  1429. ],
  1430. headers: small.headers,
  1431. model: language,
  1432. experimental_telemetry: {
  1433. isEnabled: cfg.experimental?.openTelemetry,
  1434. metadata: {
  1435. userId: cfg.username ?? "unknown",
  1436. sessionId: input.session.id,
  1437. },
  1438. },
  1439. })
  1440. .then((result) => {
  1441. if (result.text)
  1442. return Session.update(input.session.id, (draft) => {
  1443. const cleaned = result.text
  1444. .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
  1445. .split("\n")
  1446. .map((line) => line.trim())
  1447. .find((line) => line.length > 0)
  1448. if (!cleaned) return
  1449. const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
  1450. draft.title = title
  1451. })
  1452. })
  1453. .catch((error) => {
  1454. log.error("failed to generate title", { error, model: small.id })
  1455. })
  1456. }
  1457. }