prompt.ts 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912
  1. import path from "path"
  2. import os from "os"
  3. import fs from "fs/promises"
  4. import z from "zod/v4"
  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. streamText,
  15. type ModelMessage,
  16. type Tool as AITool,
  17. tool,
  18. wrapLanguageModel,
  19. type StreamTextResult,
  20. stepCountIs,
  21. jsonSchema,
  22. } from "ai"
  23. import { SessionCompaction } from "./compaction"
  24. import { SessionLock } from "./lock"
  25. import { Instance } from "../project/instance"
  26. import { Bus } from "../bus"
  27. import { ProviderTransform } from "../provider/transform"
  28. import { SystemPrompt } from "./system"
  29. import { Plugin } from "../plugin"
  30. import { SessionRetry } from "./retry"
  31. import PROMPT_PLAN from "../session/prompt/plan.txt"
  32. import BUILD_SWITCH from "../session/prompt/build-switch.txt"
  33. import { ModelsDev } from "../provider/models"
  34. import { defer } from "../util/defer"
  35. import { mergeDeep, pipe } from "remeda"
  36. import { ToolRegistry } from "../tool/registry"
  37. import { Wildcard } from "../util/wildcard"
  38. import { MCP } from "../mcp"
  39. import { LSP } from "../lsp"
  40. import { ReadTool } from "../tool/read"
  41. import { ListTool } from "../tool/ls"
  42. import { TaskTool } from "../tool/task"
  43. import { FileTime } from "../file/time"
  44. import { Permission } from "../permission"
  45. import { Snapshot } from "../snapshot"
  46. import { ulid } from "ulid"
  47. import { spawn } from "child_process"
  48. import { Command } from "../command"
  49. import { $, fileURLToPath } from "bun"
  50. import { ConfigMarkdown } from "../config/markdown"
  51. import { MessageSummary } from "./summary"
  52. export namespace SessionPrompt {
  53. const log = Log.create({ service: "session.prompt" })
  54. export const OUTPUT_TOKEN_MAX = 32_000
  55. const MAX_RETRIES = 10
  56. export const Event = {
  57. Idle: Bus.event(
  58. "session.idle",
  59. z.object({
  60. sessionID: z.string(),
  61. }),
  62. ),
  63. }
  64. const state = Instance.state(
  65. () => {
  66. const queued = new Map<
  67. string,
  68. {
  69. messageID: string
  70. callback: (input: MessageV2.WithParts) => void
  71. }[]
  72. >()
  73. return {
  74. queued,
  75. }
  76. },
  77. async (current) => {
  78. current.queued.clear()
  79. },
  80. )
  81. export const PromptInput = z.object({
  82. sessionID: Identifier.schema("session"),
  83. messageID: Identifier.schema("message").optional(),
  84. model: z
  85. .object({
  86. providerID: z.string(),
  87. modelID: z.string(),
  88. })
  89. .optional(),
  90. agent: z.string().optional(),
  91. system: z.string().optional(),
  92. tools: z.record(z.string(), z.boolean()).optional(),
  93. /**
  94. * ACP (Agent Client Protocol) connection details for streaming responses.
  95. * When provided, enables real-time streaming and tool execution visibility.
  96. */
  97. acpConnection: z
  98. .object({
  99. connection: z.any(), // AgentSideConnection - using any to avoid circular deps
  100. sessionId: z.string(), // ACP session ID (different from opencode sessionID)
  101. })
  102. .optional(),
  103. parts: z.array(
  104. z.discriminatedUnion("type", [
  105. MessageV2.TextPart.omit({
  106. messageID: true,
  107. sessionID: true,
  108. })
  109. .partial({
  110. id: true,
  111. })
  112. .meta({
  113. ref: "TextPartInput",
  114. }),
  115. MessageV2.FilePart.omit({
  116. messageID: true,
  117. sessionID: true,
  118. })
  119. .partial({
  120. id: true,
  121. })
  122. .meta({
  123. ref: "FilePartInput",
  124. }),
  125. MessageV2.AgentPart.omit({
  126. messageID: true,
  127. sessionID: true,
  128. })
  129. .partial({
  130. id: true,
  131. })
  132. .meta({
  133. ref: "AgentPartInput",
  134. }),
  135. ]),
  136. ),
  137. })
  138. export type PromptInput = z.infer<typeof PromptInput>
  139. export async function prompt(input: PromptInput): Promise<MessageV2.WithParts> {
  140. const l = log.clone().tag("session", input.sessionID)
  141. l.info("prompt")
  142. const session = await Session.get(input.sessionID)
  143. await SessionRevert.cleanup(session)
  144. const userMsg = await createUserMessage(input)
  145. await Session.touch(input.sessionID)
  146. if (isBusy(input.sessionID)) {
  147. return new Promise((resolve) => {
  148. const queue = state().queued.get(input.sessionID) ?? []
  149. queue.push({
  150. messageID: userMsg.info.id,
  151. callback: resolve,
  152. })
  153. state().queued.set(input.sessionID, queue)
  154. })
  155. }
  156. const agent = await Agent.get(input.agent ?? "build")
  157. const model = await resolveModel({
  158. agent,
  159. model: input.model,
  160. }).then((x) => Provider.getModel(x.providerID, x.modelID))
  161. using abort = lock(input.sessionID)
  162. const system = await resolveSystemPrompt({
  163. providerID: model.providerID,
  164. modelID: model.info.id,
  165. agent,
  166. system: input.system,
  167. })
  168. const processor = await createProcessor({
  169. sessionID: input.sessionID,
  170. model: model.info,
  171. providerID: model.providerID,
  172. agent: agent.name,
  173. system,
  174. abort: abort.signal,
  175. acpConnection: input.acpConnection,
  176. })
  177. const tools = await resolveTools({
  178. agent,
  179. sessionID: input.sessionID,
  180. modelID: model.modelID,
  181. providerID: model.providerID,
  182. tools: input.tools,
  183. processor,
  184. })
  185. const params = await Plugin.trigger(
  186. "chat.params",
  187. {
  188. model: model.info,
  189. provider: await Provider.getProvider(model.providerID),
  190. message: userMsg,
  191. },
  192. {
  193. temperature: model.info.temperature
  194. ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID))
  195. : undefined,
  196. topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID),
  197. options: {
  198. ...ProviderTransform.options(model.providerID, model.modelID, input.sessionID),
  199. ...model.info.options,
  200. ...agent.options,
  201. },
  202. },
  203. )
  204. let step = 0
  205. while (true) {
  206. const msgs: MessageV2.WithParts[] = pipe(
  207. await getMessages({
  208. sessionID: input.sessionID,
  209. model: model.info,
  210. providerID: model.providerID,
  211. signal: abort.signal,
  212. }),
  213. (messages) => insertReminders({ messages, agent }),
  214. )
  215. if (step === 0)
  216. ensureTitle({
  217. session,
  218. history: msgs,
  219. message: userMsg,
  220. providerID: model.providerID,
  221. modelID: model.info.id,
  222. })
  223. step++
  224. await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!)
  225. await using _ = defer(async () => {
  226. await processor.end()
  227. })
  228. const doStream = () =>
  229. streamText({
  230. onError(error) {
  231. log.error("stream error", {
  232. error,
  233. })
  234. },
  235. async experimental_repairToolCall(input) {
  236. const lower = input.toolCall.toolName.toLowerCase()
  237. if (lower !== input.toolCall.toolName && tools[lower]) {
  238. log.info("repairing tool call", {
  239. tool: input.toolCall.toolName,
  240. repaired: lower,
  241. })
  242. return {
  243. ...input.toolCall,
  244. toolName: lower,
  245. }
  246. }
  247. return {
  248. ...input.toolCall,
  249. input: JSON.stringify({
  250. tool: input.toolCall.toolName,
  251. error: input.error.message,
  252. }),
  253. toolName: "invalid",
  254. }
  255. },
  256. headers:
  257. model.providerID === "opencode"
  258. ? {
  259. "x-opencode-session": input.sessionID,
  260. "x-opencode-request": userMsg.info.id,
  261. }
  262. : undefined,
  263. // set to 0, we handle loop
  264. maxRetries: 0,
  265. activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
  266. maxOutputTokens: ProviderTransform.maxOutputTokens(
  267. model.providerID,
  268. params.options,
  269. model.info.limit.output,
  270. OUTPUT_TOKEN_MAX,
  271. ),
  272. abortSignal: abort.signal,
  273. providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options),
  274. stopWhen: stepCountIs(1),
  275. temperature: params.temperature,
  276. topP: params.topP,
  277. messages: [
  278. ...system.map(
  279. (x): ModelMessage => ({
  280. role: "system",
  281. content: x,
  282. }),
  283. ),
  284. ...MessageV2.toModelMessage(
  285. msgs.filter((m) => {
  286. if (m.info.role !== "assistant" || m.info.error === undefined) {
  287. return true
  288. }
  289. if (
  290. MessageV2.AbortedError.isInstance(m.info.error) &&
  291. m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
  292. ) {
  293. return true
  294. }
  295. return false
  296. }),
  297. ),
  298. ],
  299. tools: model.info.tool_call === false ? undefined : tools,
  300. model: wrapLanguageModel({
  301. model: model.language,
  302. middleware: [
  303. {
  304. async transformParams(args) {
  305. if (args.type === "stream") {
  306. // @ts-expect-error
  307. args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
  308. }
  309. return args.params
  310. },
  311. },
  312. ],
  313. }),
  314. })
  315. let stream = doStream()
  316. let result = await processor.process(stream, {
  317. count: 0,
  318. max: MAX_RETRIES,
  319. })
  320. if (result.shouldRetry) {
  321. for (let retry = 1; retry < MAX_RETRIES; retry++) {
  322. const lastRetryPart = result.parts.findLast((p) => p.type === "retry")
  323. if (lastRetryPart) {
  324. const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry)
  325. log.info("retrying with backoff", {
  326. attempt: retry,
  327. delayMs,
  328. })
  329. const stop = await SessionRetry.sleep(delayMs, abort.signal)
  330. .then(() => false)
  331. .catch((error) => {
  332. if (error instanceof DOMException && error.name === "AbortError") {
  333. const err = new MessageV2.AbortedError(
  334. { message: error.message },
  335. {
  336. cause: error,
  337. },
  338. ).toObject()
  339. result.info.error = err
  340. Bus.publish(Session.Event.Error, {
  341. sessionID: result.info.sessionID,
  342. error: result.info.error,
  343. })
  344. return true
  345. }
  346. throw error
  347. })
  348. if (stop) break
  349. }
  350. stream = doStream()
  351. result = await processor.process(stream, {
  352. count: retry,
  353. max: MAX_RETRIES,
  354. })
  355. if (!result.shouldRetry) {
  356. break
  357. }
  358. }
  359. }
  360. await processor.end()
  361. const queued = state().queued.get(input.sessionID) ?? []
  362. if (!result.blocked && !result.info.error) {
  363. if ((await stream.finishReason) === "tool-calls") {
  364. continue
  365. }
  366. const unprocessed = queued.filter((x) => x.messageID > result.info.id)
  367. if (unprocessed.length) {
  368. continue
  369. }
  370. }
  371. for (const item of queued) {
  372. item.callback(result)
  373. }
  374. state().queued.delete(input.sessionID)
  375. SessionCompaction.prune(input)
  376. MessageSummary.summarize({
  377. sessionID: input.sessionID,
  378. messageID: result.info.parentID,
  379. providerID: model.providerID,
  380. })
  381. return result
  382. }
  383. }
  384. async function getMessages(input: {
  385. sessionID: string
  386. model: ModelsDev.Model
  387. providerID: string
  388. signal: AbortSignal
  389. }) {
  390. let msgs = await Session.messages(input.sessionID).then(MessageV2.filterCompacted)
  391. const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")
  392. if (
  393. lastAssistant?.info.role === "assistant" &&
  394. SessionCompaction.isOverflow({
  395. tokens: lastAssistant.info.tokens,
  396. model: input.model,
  397. })
  398. ) {
  399. const summaryMsg = await SessionCompaction.run({
  400. sessionID: input.sessionID,
  401. providerID: input.providerID,
  402. modelID: input.model.id,
  403. signal: input.signal,
  404. })
  405. const resumeMsgID = Identifier.ascending("message")
  406. const resumeMsg = {
  407. info: await Session.updateMessage({
  408. id: resumeMsgID,
  409. role: "user",
  410. sessionID: input.sessionID,
  411. time: {
  412. created: Date.now(),
  413. },
  414. }),
  415. parts: [
  416. await Session.updatePart({
  417. type: "text",
  418. sessionID: input.sessionID,
  419. messageID: resumeMsgID,
  420. id: Identifier.ascending("part"),
  421. text: "Use the above summary generated from your last session to resume from where you left off.",
  422. time: {
  423. start: Date.now(),
  424. end: Date.now(),
  425. },
  426. synthetic: true,
  427. }),
  428. ],
  429. }
  430. msgs = [summaryMsg, resumeMsg]
  431. }
  432. return msgs
  433. }
  434. async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) {
  435. if (input.model) {
  436. return input.model
  437. }
  438. if (input.agent.model) {
  439. return input.agent.model
  440. }
  441. return Provider.defaultModel()
  442. }
  443. async function resolveSystemPrompt(input: {
  444. system?: string
  445. agent: Agent.Info
  446. providerID: string
  447. modelID: string
  448. }) {
  449. let system = SystemPrompt.header(input.providerID)
  450. system.push(
  451. ...(() => {
  452. if (input.system) return [input.system]
  453. if (input.agent.prompt) return [input.agent.prompt]
  454. return SystemPrompt.provider(input.modelID)
  455. })(),
  456. )
  457. system.push(...(await SystemPrompt.environment()))
  458. system.push(...(await SystemPrompt.custom()))
  459. // max 2 system prompt messages for caching purposes
  460. const [first, ...rest] = system
  461. system = [first, rest.join("\n")]
  462. return system
  463. }
  464. async function resolveTools(input: {
  465. agent: Agent.Info
  466. sessionID: string
  467. modelID: string
  468. providerID: string
  469. tools?: Record<string, boolean>
  470. processor: Processor
  471. }) {
  472. const tools: Record<string, AITool> = {}
  473. const enabledTools = pipe(
  474. input.agent.tools,
  475. mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, input.agent)),
  476. mergeDeep(input.tools ?? {}),
  477. )
  478. for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
  479. if (Wildcard.all(item.id, enabledTools) === false) continue
  480. const schema = ProviderTransform.schema(input.providerID, input.modelID, z.toJSONSchema(item.parameters))
  481. tools[item.id] = tool({
  482. id: item.id as any,
  483. description: item.description,
  484. inputSchema: jsonSchema(schema as any),
  485. async execute(args, options) {
  486. await Plugin.trigger(
  487. "tool.execute.before",
  488. {
  489. tool: item.id,
  490. sessionID: input.sessionID,
  491. callID: options.toolCallId,
  492. },
  493. {
  494. args,
  495. },
  496. )
  497. const result = await item.execute(args, {
  498. sessionID: input.sessionID,
  499. abort: options.abortSignal!,
  500. messageID: input.processor.message.id,
  501. callID: options.toolCallId,
  502. extra: {
  503. modelID: input.modelID,
  504. providerID: input.providerID,
  505. },
  506. agent: input.agent.name,
  507. metadata: async (val) => {
  508. const match = input.processor.partFromToolCall(options.toolCallId)
  509. if (match && match.state.status === "running") {
  510. await Session.updatePart({
  511. ...match,
  512. state: {
  513. title: val.title,
  514. metadata: val.metadata,
  515. status: "running",
  516. input: args,
  517. time: {
  518. start: Date.now(),
  519. },
  520. },
  521. })
  522. }
  523. },
  524. })
  525. await Plugin.trigger(
  526. "tool.execute.after",
  527. {
  528. tool: item.id,
  529. sessionID: input.sessionID,
  530. callID: options.toolCallId,
  531. },
  532. result,
  533. )
  534. return result
  535. },
  536. toModelOutput(result) {
  537. return {
  538. type: "text",
  539. value: result.output,
  540. }
  541. },
  542. })
  543. }
  544. for (const [key, item] of Object.entries(await MCP.tools())) {
  545. if (Wildcard.all(key, enabledTools) === false) continue
  546. const execute = item.execute
  547. if (!execute) continue
  548. item.execute = async (args, opts) => {
  549. await Plugin.trigger(
  550. "tool.execute.before",
  551. {
  552. tool: key,
  553. sessionID: input.sessionID,
  554. callID: opts.toolCallId,
  555. },
  556. {
  557. args,
  558. },
  559. )
  560. const result = await execute(args, opts)
  561. const output = result.content
  562. .filter((x: any) => x.type === "text")
  563. .map((x: any) => x.text)
  564. .join("\n\n")
  565. await Plugin.trigger(
  566. "tool.execute.after",
  567. {
  568. tool: key,
  569. sessionID: input.sessionID,
  570. callID: opts.toolCallId,
  571. },
  572. result,
  573. )
  574. return {
  575. title: "",
  576. metadata: {},
  577. output,
  578. }
  579. }
  580. item.toModelOutput = (result) => {
  581. return {
  582. type: "text",
  583. value: result.output,
  584. }
  585. }
  586. tools[key] = item
  587. }
  588. return tools
  589. }
  590. async function createUserMessage(input: PromptInput) {
  591. const info: MessageV2.Info = {
  592. id: input.messageID ?? Identifier.ascending("message"),
  593. role: "user",
  594. sessionID: input.sessionID,
  595. time: {
  596. created: Date.now(),
  597. },
  598. }
  599. const parts = await Promise.all(
  600. input.parts.map(async (part): Promise<MessageV2.Part[]> => {
  601. if (part.type === "file") {
  602. const url = new URL(part.url)
  603. switch (url.protocol) {
  604. case "data:":
  605. if (part.mime === "text/plain") {
  606. return [
  607. {
  608. id: Identifier.ascending("part"),
  609. messageID: info.id,
  610. sessionID: input.sessionID,
  611. type: "text",
  612. synthetic: true,
  613. text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
  614. },
  615. {
  616. id: Identifier.ascending("part"),
  617. messageID: info.id,
  618. sessionID: input.sessionID,
  619. type: "text",
  620. synthetic: true,
  621. text: Buffer.from(part.url, "base64url").toString(),
  622. },
  623. {
  624. ...part,
  625. id: part.id ?? Identifier.ascending("part"),
  626. messageID: info.id,
  627. sessionID: input.sessionID,
  628. },
  629. ]
  630. }
  631. break
  632. case "file:":
  633. log.info("file", { mime: part.mime })
  634. // have to normalize, symbol search returns absolute paths
  635. // Decode the pathname since URL constructor doesn't automatically decode it
  636. const filepath = fileURLToPath(part.url)
  637. const stat = await Bun.file(filepath).stat()
  638. if (stat.isDirectory()) {
  639. part.mime = "application/x-directory"
  640. }
  641. if (part.mime === "text/plain") {
  642. let offset: number | undefined = undefined
  643. let limit: number | undefined = undefined
  644. const range = {
  645. start: url.searchParams.get("start"),
  646. end: url.searchParams.get("end"),
  647. }
  648. if (range.start != null) {
  649. const filePathURI = part.url.split("?")[0]
  650. let start = parseInt(range.start)
  651. let end = range.end ? parseInt(range.end) : undefined
  652. // some LSP servers (eg, gopls) don't give full range in
  653. // workspace/symbol searches, so we'll try to find the
  654. // symbol in the document to get the full range
  655. if (start === end) {
  656. const symbols = await LSP.documentSymbol(filePathURI)
  657. for (const symbol of symbols) {
  658. let range: LSP.Range | undefined
  659. if ("range" in symbol) {
  660. range = symbol.range
  661. } else if ("location" in symbol) {
  662. range = symbol.location.range
  663. }
  664. if (range?.start?.line && range?.start?.line === start) {
  665. start = range.start.line
  666. end = range?.end?.line ?? start
  667. break
  668. }
  669. }
  670. }
  671. offset = Math.max(start - 1, 0)
  672. if (end) {
  673. limit = end - offset
  674. }
  675. }
  676. const args = { filePath: filepath, offset, limit }
  677. const result = await ReadTool.init().then((t) =>
  678. t.execute(args, {
  679. sessionID: input.sessionID,
  680. abort: new AbortController().signal,
  681. agent: input.agent!,
  682. messageID: info.id,
  683. extra: { bypassCwdCheck: true },
  684. metadata: async () => {},
  685. }),
  686. )
  687. return [
  688. {
  689. id: Identifier.ascending("part"),
  690. messageID: info.id,
  691. sessionID: input.sessionID,
  692. type: "text",
  693. synthetic: true,
  694. text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
  695. },
  696. {
  697. id: Identifier.ascending("part"),
  698. messageID: info.id,
  699. sessionID: input.sessionID,
  700. type: "text",
  701. synthetic: true,
  702. text: result.output,
  703. },
  704. {
  705. ...part,
  706. id: part.id ?? Identifier.ascending("part"),
  707. messageID: info.id,
  708. sessionID: input.sessionID,
  709. },
  710. ]
  711. }
  712. if (part.mime === "application/x-directory") {
  713. const args = { path: filepath }
  714. const result = await ListTool.init().then((t) =>
  715. t.execute(args, {
  716. sessionID: input.sessionID,
  717. abort: new AbortController().signal,
  718. agent: input.agent!,
  719. messageID: info.id,
  720. extra: { bypassCwdCheck: true },
  721. metadata: async () => {},
  722. }),
  723. )
  724. return [
  725. {
  726. id: Identifier.ascending("part"),
  727. messageID: info.id,
  728. sessionID: input.sessionID,
  729. type: "text",
  730. synthetic: true,
  731. text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
  732. },
  733. {
  734. id: Identifier.ascending("part"),
  735. messageID: info.id,
  736. sessionID: input.sessionID,
  737. type: "text",
  738. synthetic: true,
  739. text: result.output,
  740. },
  741. {
  742. ...part,
  743. id: part.id ?? Identifier.ascending("part"),
  744. messageID: info.id,
  745. sessionID: input.sessionID,
  746. },
  747. ]
  748. }
  749. const file = Bun.file(filepath)
  750. FileTime.read(input.sessionID, filepath)
  751. return [
  752. {
  753. id: Identifier.ascending("part"),
  754. messageID: info.id,
  755. sessionID: input.sessionID,
  756. type: "text",
  757. text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
  758. synthetic: true,
  759. },
  760. {
  761. id: part.id ?? Identifier.ascending("part"),
  762. messageID: info.id,
  763. sessionID: input.sessionID,
  764. type: "file",
  765. url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
  766. mime: part.mime,
  767. filename: part.filename!,
  768. source: part.source,
  769. },
  770. ]
  771. }
  772. }
  773. if (part.type === "agent") {
  774. return [
  775. {
  776. id: Identifier.ascending("part"),
  777. ...part,
  778. messageID: info.id,
  779. sessionID: input.sessionID,
  780. },
  781. {
  782. id: Identifier.ascending("part"),
  783. messageID: info.id,
  784. sessionID: input.sessionID,
  785. type: "text",
  786. synthetic: true,
  787. text:
  788. "Use the above message and context to generate a prompt and call the task tool with subagent: " +
  789. part.name,
  790. },
  791. ]
  792. }
  793. return [
  794. {
  795. id: Identifier.ascending("part"),
  796. ...part,
  797. messageID: info.id,
  798. sessionID: input.sessionID,
  799. },
  800. ]
  801. }),
  802. ).then((x) => x.flat())
  803. await Plugin.trigger(
  804. "chat.message",
  805. {},
  806. {
  807. message: info,
  808. parts,
  809. },
  810. )
  811. await Session.updateMessage(info)
  812. for (const part of parts) {
  813. await Session.updatePart(part)
  814. }
  815. return {
  816. info,
  817. parts,
  818. }
  819. }
  820. function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
  821. const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
  822. if (!userMessage) return input.messages
  823. if (input.agent.name === "plan") {
  824. userMessage.parts.push({
  825. id: Identifier.ascending("part"),
  826. messageID: userMessage.info.id,
  827. sessionID: userMessage.info.sessionID,
  828. type: "text",
  829. text: PROMPT_PLAN,
  830. synthetic: true,
  831. })
  832. }
  833. const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan")
  834. if (wasPlan && input.agent.name === "build") {
  835. userMessage.parts.push({
  836. id: Identifier.ascending("part"),
  837. messageID: userMessage.info.id,
  838. sessionID: userMessage.info.sessionID,
  839. type: "text",
  840. text: BUILD_SWITCH,
  841. synthetic: true,
  842. })
  843. }
  844. return input.messages
  845. }
  846. /**
  847. * Maps tool names to ACP tool kinds for consistent categorization.
  848. * - read: Tools that read data (read, glob, grep, list, webfetch, docs)
  849. * - edit: Tools that modify state (edit, write, bash)
  850. * - other: All other tools (MCP tools, task, todowrite, etc.)
  851. */
  852. function determineToolKind(toolName: string): "read" | "edit" | "other" {
  853. const readTools = [
  854. "read",
  855. "glob",
  856. "grep",
  857. "list",
  858. "webfetch",
  859. "context7_resolve_library_id",
  860. "context7_get_library_docs",
  861. ]
  862. const editTools = ["edit", "write", "bash"]
  863. if (readTools.includes(toolName.toLowerCase())) return "read"
  864. if (editTools.includes(toolName.toLowerCase())) return "edit"
  865. return "other"
  866. }
  867. /**
  868. * Extracts file/directory locations from tool inputs for ACP notifications.
  869. * Returns array of {path} objects that ACP clients can use for navigation.
  870. *
  871. * Examples:
  872. * - read({filePath: "/foo/bar.ts"}) -> [{path: "/foo/bar.ts"}]
  873. * - glob({pattern: "*.ts", path: "/src"}) -> [{path: "/src"}]
  874. * - bash({command: "ls"}) -> [] (no file references)
  875. */
  876. function extractLocations(toolName: string, input: Record<string, any>): { path: string }[] {
  877. try {
  878. switch (toolName.toLowerCase()) {
  879. case "read":
  880. case "edit":
  881. case "write":
  882. return input["filePath"] ? [{ path: input["filePath"] }] : []
  883. case "glob":
  884. case "grep":
  885. return input["path"] ? [{ path: input["path"] }] : []
  886. case "bash":
  887. return []
  888. case "list":
  889. return input["path"] ? [{ path: input["path"] }] : []
  890. default:
  891. return []
  892. }
  893. } catch {
  894. return []
  895. }
  896. }
  897. export type Processor = Awaited<ReturnType<typeof createProcessor>>
  898. async function createProcessor(input: {
  899. sessionID: string
  900. providerID: string
  901. model: ModelsDev.Model
  902. system: string[]
  903. agent: string
  904. abort: AbortSignal
  905. acpConnection?: {
  906. connection: any
  907. sessionId: string
  908. }
  909. }) {
  910. const toolcalls: Record<string, MessageV2.ToolPart> = {}
  911. let snapshot: string | undefined
  912. let blocked = false
  913. async function createMessage(parentID: string) {
  914. const msg: MessageV2.Info = {
  915. id: Identifier.ascending("message"),
  916. parentID,
  917. role: "assistant",
  918. system: input.system,
  919. mode: input.agent,
  920. path: {
  921. cwd: Instance.directory,
  922. root: Instance.worktree,
  923. },
  924. cost: 0,
  925. tokens: {
  926. input: 0,
  927. output: 0,
  928. reasoning: 0,
  929. cache: { read: 0, write: 0 },
  930. },
  931. modelID: input.model.id,
  932. providerID: input.providerID,
  933. time: {
  934. created: Date.now(),
  935. },
  936. sessionID: input.sessionID,
  937. }
  938. await Session.updateMessage(msg)
  939. return msg
  940. }
  941. let assistantMsg: MessageV2.Assistant | undefined
  942. const result = {
  943. async end() {
  944. if (assistantMsg) {
  945. assistantMsg.time.completed = Date.now()
  946. await Session.updateMessage(assistantMsg)
  947. assistantMsg = undefined
  948. }
  949. },
  950. async next(parentID: string) {
  951. if (assistantMsg) {
  952. throw new Error("end previous assistant message first")
  953. }
  954. assistantMsg = await createMessage(parentID)
  955. return assistantMsg
  956. },
  957. get message() {
  958. if (!assistantMsg) throw new Error("call next() first before accessing message")
  959. return assistantMsg
  960. },
  961. partFromToolCall(toolCallID: string) {
  962. return toolcalls[toolCallID]
  963. },
  964. async process(stream: StreamTextResult<Record<string, AITool>, never>, retries: { count: number; max: number }) {
  965. log.info("process")
  966. if (!assistantMsg) throw new Error("call next() first before processing")
  967. let shouldRetry = false
  968. try {
  969. let currentText: MessageV2.TextPart | undefined
  970. let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
  971. for await (const value of stream.fullStream) {
  972. input.abort.throwIfAborted()
  973. log.info("part", {
  974. type: value.type,
  975. })
  976. switch (value.type) {
  977. case "start":
  978. break
  979. case "reasoning-start":
  980. if (value.id in reasoningMap) {
  981. continue
  982. }
  983. reasoningMap[value.id] = {
  984. id: Identifier.ascending("part"),
  985. messageID: assistantMsg.id,
  986. sessionID: assistantMsg.sessionID,
  987. type: "reasoning",
  988. text: "",
  989. time: {
  990. start: Date.now(),
  991. },
  992. metadata: value.providerMetadata,
  993. }
  994. break
  995. case "reasoning-delta":
  996. if (value.id in reasoningMap) {
  997. const part = reasoningMap[value.id]
  998. part.text += value.text
  999. if (value.providerMetadata) part.metadata = value.providerMetadata
  1000. if (part.text) await Session.updatePart(part)
  1001. }
  1002. break
  1003. case "reasoning-end":
  1004. if (value.id in reasoningMap) {
  1005. const part = reasoningMap[value.id]
  1006. part.text = part.text.trimEnd()
  1007. part.time = {
  1008. ...part.time,
  1009. end: Date.now(),
  1010. }
  1011. if (value.providerMetadata) part.metadata = value.providerMetadata
  1012. await Session.updatePart(part)
  1013. delete reasoningMap[value.id]
  1014. }
  1015. break
  1016. case "tool-input-start":
  1017. const part = await Session.updatePart({
  1018. id: toolcalls[value.id]?.id ?? Identifier.ascending("part"),
  1019. messageID: assistantMsg.id,
  1020. sessionID: assistantMsg.sessionID,
  1021. type: "tool",
  1022. tool: value.toolName,
  1023. callID: value.id,
  1024. state: {
  1025. status: "pending",
  1026. },
  1027. })
  1028. toolcalls[value.id] = part as MessageV2.ToolPart
  1029. // Notify ACP client of pending tool call
  1030. if (input.acpConnection) {
  1031. await input.acpConnection.connection
  1032. .sessionUpdate({
  1033. sessionId: input.acpConnection.sessionId,
  1034. update: {
  1035. sessionUpdate: "tool_call",
  1036. toolCallId: value.id,
  1037. title: value.toolName,
  1038. kind: determineToolKind(value.toolName),
  1039. status: "pending",
  1040. locations: [], // Will be populated when we have input
  1041. rawInput: {},
  1042. },
  1043. })
  1044. .catch((err: Error) => {
  1045. log.error("failed to send tool pending to ACP", { error: err })
  1046. })
  1047. }
  1048. break
  1049. case "tool-input-delta":
  1050. break
  1051. case "tool-input-end":
  1052. break
  1053. case "tool-call": {
  1054. const match = toolcalls[value.toolCallId]
  1055. if (match) {
  1056. const part = await Session.updatePart({
  1057. ...match,
  1058. tool: value.toolName,
  1059. state: {
  1060. status: "running",
  1061. input: value.input,
  1062. time: {
  1063. start: Date.now(),
  1064. },
  1065. },
  1066. metadata: value.providerMetadata,
  1067. })
  1068. toolcalls[value.toolCallId] = part as MessageV2.ToolPart
  1069. // Notify ACP client that tool is running
  1070. if (input.acpConnection) {
  1071. await input.acpConnection.connection
  1072. .sessionUpdate({
  1073. sessionId: input.acpConnection.sessionId,
  1074. update: {
  1075. sessionUpdate: "tool_call_update",
  1076. toolCallId: value.toolCallId,
  1077. status: "in_progress",
  1078. locations: extractLocations(value.toolName, value.input),
  1079. rawInput: value.input,
  1080. },
  1081. })
  1082. .catch((err: Error) => {
  1083. log.error("failed to send tool in_progress to ACP", { error: err })
  1084. })
  1085. }
  1086. }
  1087. break
  1088. }
  1089. case "tool-result": {
  1090. const match = toolcalls[value.toolCallId]
  1091. if (match && match.state.status === "running") {
  1092. await Session.updatePart({
  1093. ...match,
  1094. state: {
  1095. status: "completed",
  1096. input: value.input,
  1097. output: value.output.output,
  1098. metadata: value.output.metadata,
  1099. title: value.output.title,
  1100. time: {
  1101. start: match.state.time.start,
  1102. end: Date.now(),
  1103. },
  1104. attachments: value.output.attachments,
  1105. },
  1106. })
  1107. // Notify ACP client that tool completed
  1108. if (input.acpConnection) {
  1109. await input.acpConnection.connection
  1110. .sessionUpdate({
  1111. sessionId: input.acpConnection.sessionId,
  1112. update: {
  1113. sessionUpdate: "tool_call_update",
  1114. toolCallId: value.toolCallId,
  1115. status: "completed",
  1116. content: [
  1117. {
  1118. type: "content",
  1119. content: {
  1120. type: "text",
  1121. text: value.output.output,
  1122. },
  1123. },
  1124. ],
  1125. rawOutput: value.output,
  1126. },
  1127. })
  1128. .catch((err: Error) => {
  1129. log.error("failed to send tool completed to ACP", { error: err })
  1130. })
  1131. }
  1132. delete toolcalls[value.toolCallId]
  1133. }
  1134. break
  1135. }
  1136. case "tool-error": {
  1137. const match = toolcalls[value.toolCallId]
  1138. if (match && match.state.status === "running") {
  1139. await Session.updatePart({
  1140. ...match,
  1141. state: {
  1142. status: "error",
  1143. input: value.input,
  1144. error: (value.error as any).toString(),
  1145. metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
  1146. time: {
  1147. start: match.state.time.start,
  1148. end: Date.now(),
  1149. },
  1150. },
  1151. })
  1152. // Notify ACP client of tool error
  1153. if (input.acpConnection) {
  1154. await input.acpConnection.connection
  1155. .sessionUpdate({
  1156. sessionId: input.acpConnection.sessionId,
  1157. update: {
  1158. sessionUpdate: "tool_call_update",
  1159. toolCallId: value.toolCallId,
  1160. status: "failed",
  1161. content: [
  1162. {
  1163. type: "content",
  1164. content: {
  1165. type: "text",
  1166. text: `Error: ${(value.error as any).toString()}`,
  1167. },
  1168. },
  1169. ],
  1170. rawOutput: {
  1171. error: (value.error as any).toString(),
  1172. },
  1173. },
  1174. })
  1175. .catch((err: Error) => {
  1176. log.error("failed to send tool error to ACP", { error: err })
  1177. })
  1178. }
  1179. if (value.error instanceof Permission.RejectedError) {
  1180. blocked = true
  1181. }
  1182. delete toolcalls[value.toolCallId]
  1183. }
  1184. break
  1185. }
  1186. case "error":
  1187. throw value.error
  1188. case "start-step":
  1189. snapshot = await Snapshot.track()
  1190. await Session.updatePart({
  1191. id: Identifier.ascending("part"),
  1192. messageID: assistantMsg.id,
  1193. sessionID: assistantMsg.sessionID,
  1194. snapshot,
  1195. type: "step-start",
  1196. })
  1197. break
  1198. case "finish-step":
  1199. const usage = Session.getUsage({
  1200. model: input.model,
  1201. usage: value.usage,
  1202. metadata: value.providerMetadata,
  1203. })
  1204. assistantMsg.cost += usage.cost
  1205. assistantMsg.tokens = usage.tokens
  1206. await Session.updatePart({
  1207. id: Identifier.ascending("part"),
  1208. snapshot: await Snapshot.track(),
  1209. messageID: assistantMsg.id,
  1210. sessionID: assistantMsg.sessionID,
  1211. type: "step-finish",
  1212. tokens: usage.tokens,
  1213. cost: usage.cost,
  1214. })
  1215. await Session.updateMessage(assistantMsg)
  1216. if (snapshot) {
  1217. const patch = await Snapshot.patch(snapshot)
  1218. if (patch.files.length) {
  1219. await Session.updatePart({
  1220. id: Identifier.ascending("part"),
  1221. messageID: assistantMsg.id,
  1222. sessionID: assistantMsg.sessionID,
  1223. type: "patch",
  1224. hash: patch.hash,
  1225. files: patch.files,
  1226. })
  1227. }
  1228. snapshot = undefined
  1229. }
  1230. break
  1231. case "text-start":
  1232. currentText = {
  1233. id: Identifier.ascending("part"),
  1234. messageID: assistantMsg.id,
  1235. sessionID: assistantMsg.sessionID,
  1236. type: "text",
  1237. text: "",
  1238. time: {
  1239. start: Date.now(),
  1240. },
  1241. metadata: value.providerMetadata,
  1242. }
  1243. break
  1244. case "text-delta":
  1245. if (currentText) {
  1246. currentText.text += value.text
  1247. if (value.providerMetadata) currentText.metadata = value.providerMetadata
  1248. if (currentText.text) await Session.updatePart(currentText)
  1249. // Send streaming chunk to ACP client
  1250. if (input.acpConnection && value.text) {
  1251. await input.acpConnection.connection
  1252. .sessionUpdate({
  1253. sessionId: input.acpConnection.sessionId,
  1254. update: {
  1255. sessionUpdate: "agent_message_chunk",
  1256. content: {
  1257. type: "text",
  1258. text: value.text,
  1259. },
  1260. },
  1261. })
  1262. .catch((err: Error) => {
  1263. log.error("failed to send text delta to ACP", { error: err })
  1264. // Don't fail the whole request if ACP notification fails
  1265. })
  1266. }
  1267. }
  1268. break
  1269. case "text-end":
  1270. if (currentText) {
  1271. currentText.text = currentText.text.trimEnd()
  1272. currentText.time = {
  1273. start: Date.now(),
  1274. end: Date.now(),
  1275. }
  1276. if (value.providerMetadata) currentText.metadata = value.providerMetadata
  1277. await Session.updatePart(currentText)
  1278. }
  1279. currentText = undefined
  1280. break
  1281. case "finish":
  1282. assistantMsg.time.completed = Date.now()
  1283. await Session.updateMessage(assistantMsg)
  1284. break
  1285. default:
  1286. log.info("unhandled", {
  1287. ...value,
  1288. })
  1289. continue
  1290. }
  1291. }
  1292. } catch (e) {
  1293. log.error("process", {
  1294. error: e,
  1295. })
  1296. const error = MessageV2.fromError(e, { providerID: input.providerID })
  1297. if (retries.count < retries.max && MessageV2.APIError.isInstance(error) && error.data.isRetryable) {
  1298. shouldRetry = true
  1299. await Session.updatePart({
  1300. id: Identifier.ascending("part"),
  1301. messageID: assistantMsg.id,
  1302. sessionID: assistantMsg.sessionID,
  1303. type: "retry",
  1304. attempt: retries.count + 1,
  1305. time: {
  1306. created: Date.now(),
  1307. },
  1308. error,
  1309. })
  1310. } else {
  1311. assistantMsg.error = error
  1312. Bus.publish(Session.Event.Error, {
  1313. sessionID: assistantMsg.sessionID,
  1314. error: assistantMsg.error,
  1315. })
  1316. }
  1317. }
  1318. const p = await Session.getParts(assistantMsg.id)
  1319. for (const part of p) {
  1320. if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
  1321. Session.updatePart({
  1322. ...part,
  1323. state: {
  1324. status: "error",
  1325. error: "Tool execution aborted",
  1326. time: {
  1327. start: Date.now(),
  1328. end: Date.now(),
  1329. },
  1330. input: {},
  1331. },
  1332. })
  1333. }
  1334. }
  1335. if (!shouldRetry) {
  1336. assistantMsg.time.completed = Date.now()
  1337. }
  1338. await Session.updateMessage(assistantMsg)
  1339. return { info: assistantMsg, parts: p, blocked, shouldRetry }
  1340. },
  1341. }
  1342. return result
  1343. }
  1344. function isBusy(sessionID: string) {
  1345. return SessionLock.isLocked(sessionID)
  1346. }
  1347. function lock(sessionID: string) {
  1348. const handle = SessionLock.acquire({
  1349. sessionID,
  1350. })
  1351. log.info("locking", { sessionID })
  1352. return {
  1353. signal: handle.signal,
  1354. abort: handle.abort,
  1355. async [Symbol.dispose]() {
  1356. handle[Symbol.dispose]()
  1357. log.info("unlocking", { sessionID })
  1358. const session = await Session.get(sessionID)
  1359. if (session.parentID) return
  1360. Bus.publish(Event.Idle, {
  1361. sessionID,
  1362. })
  1363. },
  1364. }
  1365. }
  1366. export const ShellInput = z.object({
  1367. sessionID: Identifier.schema("session"),
  1368. agent: z.string(),
  1369. command: z.string(),
  1370. })
  1371. export type ShellInput = z.infer<typeof ShellInput>
  1372. export async function shell(input: ShellInput) {
  1373. using abort = lock(input.sessionID)
  1374. const session = await Session.get(input.sessionID)
  1375. if (session.revert) {
  1376. SessionRevert.cleanup(session)
  1377. }
  1378. const userMsg: MessageV2.User = {
  1379. id: Identifier.ascending("message"),
  1380. sessionID: input.sessionID,
  1381. time: {
  1382. created: Date.now(),
  1383. },
  1384. role: "user",
  1385. }
  1386. await Session.updateMessage(userMsg)
  1387. const userPart: MessageV2.Part = {
  1388. type: "text",
  1389. id: Identifier.ascending("part"),
  1390. messageID: userMsg.id,
  1391. sessionID: input.sessionID,
  1392. text: "The following tool was executed by the user",
  1393. synthetic: true,
  1394. }
  1395. await Session.updatePart(userPart)
  1396. const msg: MessageV2.Assistant = {
  1397. id: Identifier.ascending("message"),
  1398. sessionID: input.sessionID,
  1399. parentID: userMsg.id,
  1400. system: [],
  1401. mode: input.agent,
  1402. cost: 0,
  1403. path: {
  1404. cwd: Instance.directory,
  1405. root: Instance.worktree,
  1406. },
  1407. time: {
  1408. created: Date.now(),
  1409. },
  1410. role: "assistant",
  1411. tokens: {
  1412. input: 0,
  1413. output: 0,
  1414. reasoning: 0,
  1415. cache: { read: 0, write: 0 },
  1416. },
  1417. modelID: "",
  1418. providerID: "",
  1419. }
  1420. await Session.updateMessage(msg)
  1421. const part: MessageV2.Part = {
  1422. type: "tool",
  1423. id: Identifier.ascending("part"),
  1424. messageID: msg.id,
  1425. sessionID: input.sessionID,
  1426. tool: "bash",
  1427. callID: ulid(),
  1428. state: {
  1429. status: "running",
  1430. time: {
  1431. start: Date.now(),
  1432. },
  1433. input: {
  1434. command: input.command,
  1435. },
  1436. },
  1437. }
  1438. await Session.updatePart(part)
  1439. const shell = process.env["SHELL"] ?? "bash"
  1440. const shellName = path.basename(shell)
  1441. const invocations: Record<string, { args: string[] }> = {
  1442. nu: {
  1443. args: ["-c", input.command],
  1444. },
  1445. fish: {
  1446. args: ["-c", input.command],
  1447. },
  1448. zsh: {
  1449. args: [
  1450. "-c",
  1451. "-l",
  1452. `
  1453. [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
  1454. [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
  1455. ${input.command}
  1456. `,
  1457. ],
  1458. },
  1459. bash: {
  1460. args: [
  1461. "-c",
  1462. "-l",
  1463. `
  1464. [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
  1465. ${input.command}
  1466. `,
  1467. ],
  1468. },
  1469. // Fallback: any shell that doesn't match those above
  1470. "": {
  1471. args: ["-c", "-l", `${input.command}`],
  1472. },
  1473. }
  1474. const matchingInvocation = invocations[shellName] ?? invocations[""]
  1475. const args = matchingInvocation?.args
  1476. const proc = spawn(shell, args, {
  1477. cwd: Instance.directory,
  1478. signal: abort.signal,
  1479. detached: true,
  1480. stdio: ["ignore", "pipe", "pipe"],
  1481. env: {
  1482. ...process.env,
  1483. TERM: "dumb",
  1484. },
  1485. })
  1486. abort.signal.addEventListener("abort", () => {
  1487. if (!proc.pid) return
  1488. process.kill(-proc.pid)
  1489. })
  1490. let output = ""
  1491. proc.stdout?.on("data", (chunk) => {
  1492. output += chunk.toString()
  1493. if (part.state.status === "running") {
  1494. part.state.metadata = {
  1495. output: output,
  1496. description: "",
  1497. }
  1498. Session.updatePart(part)
  1499. }
  1500. })
  1501. proc.stderr?.on("data", (chunk) => {
  1502. output += chunk.toString()
  1503. if (part.state.status === "running") {
  1504. part.state.metadata = {
  1505. output: output,
  1506. description: "",
  1507. }
  1508. Session.updatePart(part)
  1509. }
  1510. })
  1511. await new Promise<void>((resolve) => {
  1512. proc.on("close", () => {
  1513. resolve()
  1514. })
  1515. })
  1516. msg.time.completed = Date.now()
  1517. await Session.updateMessage(msg)
  1518. if (part.state.status === "running") {
  1519. part.state = {
  1520. status: "completed",
  1521. time: {
  1522. ...part.state.time,
  1523. end: Date.now(),
  1524. },
  1525. input: part.state.input,
  1526. title: "",
  1527. metadata: {
  1528. output,
  1529. description: "",
  1530. },
  1531. output,
  1532. }
  1533. await Session.updatePart(part)
  1534. }
  1535. return { info: msg, parts: [part] }
  1536. }
  1537. export const CommandInput = z.object({
  1538. messageID: Identifier.schema("message").optional(),
  1539. sessionID: Identifier.schema("session"),
  1540. agent: z.string().optional(),
  1541. model: z.string().optional(),
  1542. arguments: z.string(),
  1543. command: z.string(),
  1544. })
  1545. export type CommandInput = z.infer<typeof CommandInput>
  1546. const bashRegex = /!`([^`]+)`/g
  1547. /**
  1548. * Regular expression to match @ file references in text
  1549. * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
  1550. * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
  1551. */
  1552. export async function command(input: CommandInput) {
  1553. log.info("command", input)
  1554. const command = await Command.get(input.command)
  1555. const agentName = command.agent ?? input.agent ?? "build"
  1556. let template = command.template.replaceAll("$ARGUMENTS", input.arguments)
  1557. const shell = ConfigMarkdown.shell(template)
  1558. if (shell.length > 0) {
  1559. const results = await Promise.all(
  1560. shell.map(async ([, cmd]) => {
  1561. try {
  1562. return await $`${{ raw: cmd }}`.nothrow().text()
  1563. } catch (error) {
  1564. return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
  1565. }
  1566. }),
  1567. )
  1568. let index = 0
  1569. template = template.replace(bashRegex, () => results[index++])
  1570. }
  1571. const parts = [
  1572. {
  1573. type: "text",
  1574. text: template,
  1575. },
  1576. ] as PromptInput["parts"]
  1577. const files = ConfigMarkdown.files(template)
  1578. await Promise.all(
  1579. files.map(async (match) => {
  1580. const name = match[1]
  1581. const filepath = name.startsWith("~/")
  1582. ? path.join(os.homedir(), name.slice(2))
  1583. : path.resolve(Instance.worktree, name)
  1584. const stats = await fs.stat(filepath).catch(() => undefined)
  1585. if (!stats) {
  1586. const agent = await Agent.get(name)
  1587. if (agent) {
  1588. parts.push({
  1589. type: "agent",
  1590. name: agent.name,
  1591. })
  1592. }
  1593. return
  1594. }
  1595. if (stats.isDirectory()) {
  1596. parts.push({
  1597. type: "file",
  1598. url: `file://${filepath}`,
  1599. filename: name,
  1600. mime: "application/x-directory",
  1601. })
  1602. return
  1603. }
  1604. parts.push({
  1605. type: "file",
  1606. url: `file://${filepath}`,
  1607. filename: name,
  1608. mime: "text/plain",
  1609. })
  1610. }),
  1611. )
  1612. const model = await (async () => {
  1613. if (command.model) {
  1614. return Provider.parseModel(command.model)
  1615. }
  1616. if (command.agent) {
  1617. const cmdAgent = await Agent.get(command.agent)
  1618. if (cmdAgent.model) {
  1619. return cmdAgent.model
  1620. }
  1621. }
  1622. if (input.model) {
  1623. return Provider.parseModel(input.model)
  1624. }
  1625. return await Provider.defaultModel()
  1626. })()
  1627. const agent = await Agent.get(agentName)
  1628. if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) {
  1629. using abort = lock(input.sessionID)
  1630. const userMsg: MessageV2.User = {
  1631. id: Identifier.ascending("message"),
  1632. sessionID: input.sessionID,
  1633. time: {
  1634. created: Date.now(),
  1635. },
  1636. role: "user",
  1637. }
  1638. await Session.updateMessage(userMsg)
  1639. const userPart: MessageV2.Part = {
  1640. type: "text",
  1641. id: Identifier.ascending("part"),
  1642. messageID: userMsg.id,
  1643. sessionID: input.sessionID,
  1644. text: "The following tool was executed by the user",
  1645. synthetic: true,
  1646. }
  1647. await Session.updatePart(userPart)
  1648. const assistantMsg: MessageV2.Assistant = {
  1649. id: Identifier.ascending("message"),
  1650. sessionID: input.sessionID,
  1651. parentID: userMsg.id,
  1652. system: [],
  1653. mode: agentName,
  1654. cost: 0,
  1655. path: {
  1656. cwd: Instance.directory,
  1657. root: Instance.worktree,
  1658. },
  1659. time: {
  1660. created: Date.now(),
  1661. },
  1662. role: "assistant",
  1663. tokens: {
  1664. input: 0,
  1665. output: 0,
  1666. reasoning: 0,
  1667. cache: { read: 0, write: 0 },
  1668. },
  1669. modelID: model.modelID,
  1670. providerID: model.providerID,
  1671. }
  1672. await Session.updateMessage(assistantMsg)
  1673. const args = {
  1674. description: "Consulting " + agent.name,
  1675. subagent_type: agent.name,
  1676. prompt: template,
  1677. }
  1678. const toolPart: MessageV2.ToolPart = {
  1679. type: "tool",
  1680. id: Identifier.ascending("part"),
  1681. messageID: assistantMsg.id,
  1682. sessionID: input.sessionID,
  1683. tool: "task",
  1684. callID: ulid(),
  1685. state: {
  1686. status: "running",
  1687. time: {
  1688. start: Date.now(),
  1689. },
  1690. input: {
  1691. description: args.description,
  1692. subagent_type: args.subagent_type,
  1693. // truncate prompt to preserve context
  1694. prompt: args.prompt.length > 100 ? args.prompt.substring(0, 97) + "..." : args.prompt,
  1695. },
  1696. },
  1697. }
  1698. await Session.updatePart(toolPart)
  1699. const result = await TaskTool.init().then((t) =>
  1700. t.execute(args, {
  1701. sessionID: input.sessionID,
  1702. abort: abort.signal,
  1703. agent: agent.name,
  1704. messageID: assistantMsg.id,
  1705. extra: {},
  1706. metadata: async (metadata) => {
  1707. if (toolPart.state.status === "running") {
  1708. toolPart.state.metadata = metadata.metadata
  1709. toolPart.state.title = metadata.title
  1710. await Session.updatePart(toolPart)
  1711. }
  1712. },
  1713. }),
  1714. )
  1715. assistantMsg.time.completed = Date.now()
  1716. await Session.updateMessage(assistantMsg)
  1717. if (toolPart.state.status === "running") {
  1718. toolPart.state = {
  1719. status: "completed",
  1720. time: {
  1721. ...toolPart.state.time,
  1722. end: Date.now(),
  1723. },
  1724. input: toolPart.state.input,
  1725. title: "",
  1726. metadata: result.metadata,
  1727. output: result.output,
  1728. }
  1729. await Session.updatePart(toolPart)
  1730. }
  1731. return { info: assistantMsg, parts: [toolPart] }
  1732. }
  1733. return prompt({
  1734. sessionID: input.sessionID,
  1735. messageID: input.messageID,
  1736. model,
  1737. agent: agentName,
  1738. parts,
  1739. })
  1740. }
  1741. async function ensureTitle(input: {
  1742. session: Session.Info
  1743. message: MessageV2.WithParts
  1744. history: MessageV2.WithParts[]
  1745. providerID: string
  1746. modelID: string
  1747. }) {
  1748. if (input.session.parentID) return
  1749. const isFirst =
  1750. input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
  1751. .length === 1
  1752. if (!isFirst) return
  1753. const small =
  1754. (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
  1755. const options = {
  1756. ...ProviderTransform.options(small.providerID, small.modelID, input.session.id),
  1757. ...small.info.options,
  1758. }
  1759. if (small.providerID === "openai" || small.modelID.includes("gpt-5")) {
  1760. options["reasoningEffort"] = "minimal"
  1761. }
  1762. if (small.providerID === "google") {
  1763. options["thinkingConfig"] = {
  1764. thinkingBudget: 0,
  1765. }
  1766. }
  1767. generateText({
  1768. maxOutputTokens: small.info.reasoning ? 1500 : 20,
  1769. providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
  1770. messages: [
  1771. ...SystemPrompt.title(small.providerID).map(
  1772. (x): ModelMessage => ({
  1773. role: "system",
  1774. content: x,
  1775. }),
  1776. ),
  1777. ...MessageV2.toModelMessage([
  1778. {
  1779. info: {
  1780. id: Identifier.ascending("message"),
  1781. role: "user",
  1782. sessionID: input.session.id,
  1783. time: {
  1784. created: Date.now(),
  1785. },
  1786. },
  1787. parts: input.message.parts,
  1788. },
  1789. ]),
  1790. ],
  1791. model: small.language,
  1792. })
  1793. .then((result) => {
  1794. if (result.text)
  1795. return Session.update(input.session.id, (draft) => {
  1796. const cleaned = result.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
  1797. const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
  1798. draft.title = title.trim()
  1799. })
  1800. })
  1801. .catch((error) => {
  1802. log.error("failed to generate title", { error, model: small.info.id })
  1803. })
  1804. }
  1805. }