prompt.ts 45 KB

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