prompt.ts 57 KB

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