prompt.ts 55 KB

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