prompt.ts 58 KB

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